diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 331f950cc..700e93a07 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -2,12 +2,13 @@ import { EnsureWorkspace } from "@/components/ensure-workspace" import { PWAInstallPrompt } from "@/components/pwa-install-prompt" +import { SettingsModalProvider } from "@/components/settings/settings-modal" export default function AppLayout({ children }: { children: React.ReactNode }) { return ( - <> + {children} - > + ) } diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx index 51745b80a..32b741262 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/settings/page.tsx @@ -1,756 +1,23 @@ "use client" -import { Logo } from "@ui/assets/Logo" -import { UserProfileMenu } from "@/components/user-profile-menu" -import { useAuth } from "@lib/auth-context" -import NovaOrb from "@/components/nova/nova-orb" -import { useState, useEffect, useRef, useMemo } from "react" -import { cn } from "@lib/utils" -import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" -import Account from "@/components/settings/account" -import Billing from "@/components/settings/billing" -import Integrations from "@/components/settings/integrations" -import ConnectionsMCP from "@/components/settings/connections-mcp" -import Support from "@/components/settings/support" -import { ErrorBoundary } from "@/components/error-boundary" -import { useRouter } from "next/navigation" -import { useIsMobile } from "@hooks/use-mobile" -import { useLocalStorageUsername } from "@hooks/use-local-storage-username" -import { analytics } from "@/lib/analytics" -import { - LogOut, - RotateCcw, - Trash2, - Sun, - LoaderIcon, - User as UserIcon, - Zap, - HelpCircle, - CreditCard, - ShieldAlert, - ChevronRight, - ChevronsUpDown, - Check, - Building2, -} from "lucide-react" -import { authClient } from "@lib/auth" -import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog" -import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover" -import { useResetOrganization } from "@/hooks/use-reset-organization" -import { useDeleteUserAccount } from "@/hooks/use-account-settings" -import { useCustomer } from "autumn-js/react" -import { useOrgSummaries } from "@/hooks/use-org-summaries" -import { - PLAN_DISPLAY_NAMES, - useTokenUsage, - type PlanType, -} from "@/hooks/use-token-usage" - -const TABS = [ - "account", - "billing", - "integrations", - "connections", - "support", -] as const -type SettingsTab = (typeof TABS)[number] - -type NavItem = { - id: SettingsTab - label: string - description: string - icon: React.ReactNode -} - -const NAV_ITEMS: NavItem[] = [ - { - id: "account", - label: "Account", - description: "Your profile and organization", - icon: , - }, - { - id: "billing", - label: "Billing", - description: "Plan, usage and payments", - icon: , - }, - { - id: "integrations", - label: "Integrations", - description: "Save, sync and search across tools", - icon: , - }, - { - id: "connections", - label: "Connections & MCP", - description: "Drive, Notion, OneDrive, MCP", - icon: , - }, - { - id: "support", - label: "Support & Help", - description: "Get help or share feedback", - icon: , - }, -] - -function parseHashToTab(hash: string): SettingsTab { - const cleaned = hash.replace("#", "").toLowerCase() - return TABS.includes(cleaned as SettingsTab) - ? (cleaned as SettingsTab) - : "account" -} - -const ORG_PLAN_BADGE_STYLES: Record = { - free: "bg-[#2E353D] font-mono font-medium tracking-[0.12em] text-[#A3A3A3]", - pro: "bg-[#4BA0FA] font-bold tracking-[0.36px] text-[#00171A]", - max: "bg-[#1E7FE0] font-bold tracking-[0.36px] text-[#00171A]", - scale: "bg-[#0054AD] font-bold tracking-[0.36px] text-[#FAFAFA]", - enterprise: "bg-[#FAFAFA] font-bold tracking-[0.36px] text-[#0D121A]", -} - -function OrgPlanBadge({ plan }: { plan: PlanType }) { - return ( - - {PLAN_DISPLAY_NAMES[plan]} - - ) -} -function resolveOrgPlan( - orgId: string, - isCurrent: boolean, - currentPlan: PlanType, - planByOrgId: Map, -): PlanType { - const fromSummary = planByOrgId.get(orgId) - if (fromSummary) return fromSummary - if (isCurrent) return currentPlan - return "free" -} - -function SectionLabel({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ) -} - -function IdentityCard({ displayName }: { displayName: string }) { - const firstName = displayName?.split(" ")[0] || "" - - return ( - - - - - - - {firstName ? `${firstName}'s` : "Your"} - - - supermemory - - - - - ) -} +import { useEffect } from "react" +import { useRouter } from "next/navigation" +import { parseHashToTab } from "@/components/settings/settings-content" -export default function SettingsPage() { - const { user, org, organizations, setActiveOrg } = useAuth() - const [activeTab, setActiveTab] = useState("account") - const hasInitialized = useRef(false) +/** + * Legacy redirect: /settings (and /settings#billing etc.) now open the + * settings modal via the ?settings= URL state on the home page. + */ +export default function SettingsRedirect() { const router = useRouter() - const isMobile = useIsMobile() - const localStorageUsername = useLocalStorageUsername() - - const [isResetDialogOpen, setIsResetDialogOpen] = useState(false) - const [resetConfirmation, setResetConfirmation] = useState("") - const resetOrganization = useResetOrganization() - - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) - const [deleteEmailConfirm, setDeleteEmailConfirm] = useState("") - const deleteUserAccount = useDeleteUserAccount() - - const [dangerMenuOpen, setDangerMenuOpen] = useState(false) - const [orgSwitcherOpen, setOrgSwitcherOpen] = useState(false) - const [switchingOrgId, setSwitchingOrgId] = useState(null) - const canSwitchOrg = (organizations?.length ?? 0) > 1 - - const autumn = useCustomer() - const { currentPlan } = useTokenUsage(autumn) - const { data: orgSummaries } = useOrgSummaries() - const planByOrgId = useMemo(() => { - const map = new Map() - for (const summary of orgSummaries ?? []) { - map.set(summary.orgId, summary.plan) - } - return map - }, [orgSummaries]) - const activeOrgPlan = org?.id - ? resolveOrgPlan(org.id, true, currentPlan, planByOrgId) - : currentPlan - - const handleOrgSwitch = async (orgSlug: string, orgId: string) => { - if (orgId === org?.id) { - setOrgSwitcherOpen(false) - return - } - setSwitchingOrgId(orgId) - try { - await setActiveOrg(orgSlug) - window.location.reload() - } catch (error) { - console.error("Failed to switch organization:", error) - setSwitchingOrgId(null) - } - } - - const handleLogout = async () => { - await authClient.signOut() - router.push("/login") - } - - const handleDeleteAccount = async () => { - if (deleteEmailConfirm !== user?.email) return - deleteUserAccount.mutate( - { confirmation: deleteEmailConfirm }, - { - onSuccess: () => { - setIsDeleteDialogOpen(false) - setDeleteEmailConfirm("") - router.push("/login") - }, - }, - ) - } useEffect(() => { - if (hasInitialized.current) return - hasInitialized.current = true - - const hash = window.location.hash + const hash = typeof window !== "undefined" ? window.location.hash : "" const tab = parseHashToTab(hash) - setActiveTab(tab) - analytics.settingsTabChanged({ tab }) - - if (!hash || !TABS.includes(hash.replace("#", "") as SettingsTab)) { - window.history.pushState(null, "", "#account") - } - }, []) - - useEffect(() => { - const handleHashChange = () => { - const tab = parseHashToTab(window.location.hash) - setActiveTab(tab) - analytics.settingsTabChanged({ tab }) - } - - window.addEventListener("hashchange", handleHashChange) - return () => window.removeEventListener("hashchange", handleHashChange) - }, []) - - const headerDisplayName = - user?.displayUsername || - localStorageUsername || - user?.name || - user?.email?.split("@")[0] || - "" - - return ( - - - - router.push("/")} - className={cn( - "flex items-center min-w-0 rounded-lg py-1 pr-2 -ml-1 pl-1", - "hover:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-colors cursor-pointer text-left", - )} - > - - - supermemory - - - - / - - Settings - - - {!isMobile && - (canSwitchOrg ? ( - - - - - - - - {org?.name ?? "Personal"} - - - - - - - {[...(organizations ?? [])] - .sort((a, b) => a.name.localeCompare(b.name)) - .map((organization) => { - const isCurrent = organization.id === org?.id - const isSwitching = switchingOrgId === organization.id - const plan = resolveOrgPlan( - organization.id, - isCurrent, - currentPlan, - planByOrgId, - ) - return ( - - handleOrgSwitch(organization.slug, organization.id) - } - className={cn( - "w-full flex items-center gap-2.5 rounded-[10px] px-3 py-2 text-left transition-colors", - isCurrent - ? "bg-white/5" - : "hover:bg-white/5 cursor-pointer", - "disabled:cursor-default", - )} - > - - - {organization.name} - - {isSwitching ? ( - - ) : isCurrent ? ( - - ) : null} - - - ) - })} - - - ) : ( - - - - - - {org?.name ?? "Personal"} - - - - ))} - - - - - - - {/* Left rail */} - - - {/* Content */} - - - Something went wrong loading this section.{" "} - window.location.reload()} - > - Reload - - - } - > - {activeTab === "account" && } - {activeTab === "billing" && } - {activeTab === "integrations" && } - {activeTab === "connections" && } - {activeTab === "support" && } - - - - - - {/* Reset data dialog */} - {(() => { - const confirmText = org?.name || user?.name || "" - return ( - { - setIsResetDialogOpen(open) - if (!open) setResetConfirmation("") - }} - > - - - - - Reset all data? - - - This permanently removes: - - - All documents and memories - All connections (Google Drive, Notion, etc.) - All custom spaces (default space stays) - Organization settings and filters - - - Your account and billing plan stay intact.{" "} - - This cannot be undone. - - - - - - Type{" "} - - {confirmText || "your name"} - {" "} - to confirm: - - setResetConfirmation(e.target.value)} - placeholder={confirmText || "Your name"} - autoComplete="off" - className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C7991B]/50 transition-colors" - /> - - - - - Cancel - - - - resetOrganization.mutate( - { confirmation: confirmText }, - { - onSuccess: () => { - setIsResetDialogOpen(false) - setResetConfirmation("") - }, - }, - ) - } - className="flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#1A1200] text-[#C7991B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90" - > - {resetOrganization.isPending ? ( - - ) : ( - - )} - {resetOrganization.isPending - ? "Resetting…" - : "Reset organization"} - - - - - - ) - })()} + router.replace( + tab === "integrations" ? "/?view=integrations" : `/?settings=${tab}`, + ) + }, [router]) - {/* Delete account dialog */} - { - setIsDeleteDialogOpen(open) - if (!open) setDeleteEmailConfirm("") - }} - > - - - - - Delete your account? - - - Permanently deletes all your data and cancels any active - subscriptions.{" "} - - This cannot be undone. - - - - - - Type your email{" "} - {user?.email} to - confirm: - - setDeleteEmailConfirm(e.target.value)} - placeholder={user?.email ?? "your@email.com"} - className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C73B1B]/50 transition-colors" - /> - - - - - Cancel - - - - {deleteUserAccount.isPending ? ( - - ) : ( - - )} - {deleteUserAccount.isPending ? "Deleting…" : "Delete account"} - - - - - - - ) + return null } diff --git a/apps/web/components/bottom-nav.tsx b/apps/web/components/bottom-nav.tsx index cf0a39716..af72c5a67 100644 --- a/apps/web/components/bottom-nav.tsx +++ b/apps/web/components/bottom-nav.tsx @@ -10,7 +10,6 @@ import { LifeBuoy, Settings, } from "lucide-react" -import { useRouter } from "next/navigation" import { useQueryState } from "nuqs" import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" @@ -25,6 +24,7 @@ import { import { useViewMode, type ViewMode } from "@/lib/view-mode-context" import { feedbackParam } from "@/lib/search-params" import NovaOrb from "@/components/nova/nova-orb" +import { useSettingsModal } from "@/components/settings/settings-modal" const INTEGRATION_VIEWS: ViewMode[] = [ "integrations", @@ -43,7 +43,7 @@ interface BottomNavProps { } export function MobileBottomNav({ onAddMemory, onOpenSearch }: BottomNavProps) { - const router = useRouter() + const { openSettings } = useSettingsModal() const { viewMode, setViewMode } = useViewMode() const [, setFeedbackOpen] = useQueryState("feedback", feedbackParam) @@ -127,7 +127,7 @@ export function MobileBottomNav({ onAddMemory, onOpenSearch }: BottomNavProps) { router.push("/settings")} + onClick={() => openSettings()} /> diff --git a/apps/web/components/documents-command-palette.tsx b/apps/web/components/documents-command-palette.tsx index 11b9151e8..6e711b291 100644 --- a/apps/web/components/documents-command-palette.tsx +++ b/apps/web/components/documents-command-palette.tsx @@ -14,6 +14,7 @@ import { useIsMobile } from "@hooks/use-mobile" import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog" import { SearchIcon, Settings, Home, Plus, Code2, Loader2 } from "lucide-react" import { DocumentIcon } from "@/components/document-icon" +import { useSettingsModal } from "@/components/settings/settings-modal" import { $fetch } from "@lib/api" type DocumentsResponse = z.infer @@ -52,6 +53,7 @@ export function DocumentsCommandPalette({ }: DocumentsCommandPaletteProps) { const isMobile = useIsMobile() const router = useRouter() + const { openSettings } = useSettingsModal() const queryClient = useQueryClient() const [search, setSearch] = useState("") const [selectedIndex, setSelectedIndex] = useState(0) @@ -86,7 +88,7 @@ export function DocumentsCommandPalette({ id: "settings", label: "Go to Settings", icon: , - action: () => close(() => router.push("/settings")), + action: () => close(() => openSettings()), }, ...(onAddMemory ? [ diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx index c70c62d07..cc3afb71e 100644 --- a/apps/web/components/header.tsx +++ b/apps/web/components/header.tsx @@ -30,7 +30,6 @@ import { } from "@ui/components/dropdown-menu" import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip" import { useProject } from "@/stores" -import { useRouter } from "next/navigation" import Link from "next/link" import { SpaceSelector } from "./space-selector" import { useIsMobile } from "@hooks/use-mobile" @@ -44,6 +43,7 @@ import { useCustomer } from "autumn-js/react" import { useTokenUsage } from "@/hooks/use-token-usage" import { useOrgSummaries } from "@/hooks/use-org-summaries" import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge" +import { useSettingsModal } from "@/components/settings/settings-modal" interface HeaderProps { onAddMemory?: () => void @@ -74,7 +74,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) { (orgSummaries ?? []).map((s) => [s.orgId, s.plan] as const), ) const { selectedProjects, setSelectedProjects } = useProject() - const router = useRouter() + const { openSettings } = useSettingsModal() const isMobile = useIsMobile() const [feedbackOpen, setFeedbackOpen] = useQueryState( "feedback", @@ -421,7 +421,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) { Feedback router.push("/settings")} + onClick={() => openSettings()} className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer" > diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx index 8b5f042b9..97466f356 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -732,15 +732,23 @@ function FeaturedHero({ picks }: { picks: FeaturedPick[] }) { if (!pick) return null return ( - + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + pick.onCta() + } + }} onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)} onFocus={() => setPaused(true)} onBlur={() => setPaused(false)} className={cn( - "group relative w-full overflow-hidden rounded-[14px] px-5 py-5 sm:px-6 sm:py-6 text-left cursor-pointer", + "group relative flex w-full flex-col items-start justify-center overflow-hidden rounded-[14px] px-5 py-5 sm:px-6 sm:py-6 text-left cursor-pointer", "bg-[#191D24]", "shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]", "min-h-[160px]", @@ -844,7 +852,7 @@ function FeaturedHero({ picks }: { picks: FeaturedPick[] }) { - + ) } diff --git a/apps/web/components/settings/account.tsx b/apps/web/components/settings/account.tsx index fa3d7dc9d..347f58bec 100644 --- a/apps/web/components/settings/account.tsx +++ b/apps/web/components/settings/account.tsx @@ -40,13 +40,18 @@ import { toast } from "sonner" import { useContainerTags } from "@/hooks/use-container-tags" import { PopoverAnchor } from "@ui/components/popover" import { OrgContext } from "@/components/settings/org-context" +import { OrgPlanBadge } from "@/components/org-plan-badge" +import { useTokenUsage } from "@/hooks/use-token-usage" +import { useOrgSummaries } from "@/hooks/use-org-summaries" +import { useCustomer } from "autumn-js/react" +import { FileText, Layers, Plug, Search } from "lucide-react" function SectionTitle({ children }: { children: React.ReactNode }) { return ( {children} @@ -127,6 +132,10 @@ function isPendingInvitation(invitation: { export default function Account() { const { user, org, refetchActiveOrg, refetchOrganizations } = useAuth() + const autumn = useCustomer() + const { currentPlan, searchesUsed } = useTokenUsage(autumn) + const { data: orgSummaries } = useOrgSummaries() + const orgSummary = orgSummaries?.find((s) => s.orgId === org?.id) const [inviteDialogOpen, setInviteDialogOpen] = useState(false) const [inviteEmail, setInviteEmail] = useState("") const [inviteRole, setInviteRole] = useState("member") @@ -202,6 +211,11 @@ export default function Account() { [org?.invitations], ) + const showTeamCard = + !canManageTeam || + pendingInvitations.length > 0 || + (org?.members?.length ?? 0) > 1 + const resetInviteForm = () => { setInviteEmail("") setInviteRole("member") @@ -372,30 +386,29 @@ export default function Account() { : "—" return ( - - - Profile Details + + - - {/* Avatar + Name/Email */} - - + + {/* Identity */} + + - + {user?.name?.charAt(0) ?? "U"} - + {user?.name ?? "—"} @@ -403,28 +416,32 @@ export default function Account() { {user?.email ?? "—"} + + + - - - + + Organization - + {isEditingOrgName ? ( - - - {updateOrgNameMutation.isPending ? ( - - ) : null} - Save - - { - setOrgNameDraft(org?.name ?? "") - setIsEditingOrgName(false) - }} - className={cn( - "inline-flex size-8 items-center justify-center rounded-full bg-[#0D121A] text-[#737373] shadow-inside-out transition-colors hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-50", - )} - > - - - + + {updateOrgNameMutation.isPending ? ( + + ) : null} + Save + + { + setOrgNameDraft(org?.name ?? "") + setIsEditingOrgName(false) + }} + className="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#737373] shadow-inside-out transition-colors hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-50" + > + + ) : ( - + {org?.name ?? "Personal"} @@ -491,49 +504,110 @@ export default function Account() { setOrgNameDraft(org?.name ?? "") setIsEditingOrgName(true) }} - className={cn( - "inline-flex size-7 shrink-0 items-center justify-center rounded-md text-[#FAFAFA] transition-colors hover:bg-white/5", - )} + className="inline-flex size-5 shrink-0 items-center justify-center rounded-md text-[#737373] transition-colors hover:bg-white/5 hover:text-[#FAFAFA]" > - + ) : null} )} - - + Member since - - + {memberSince} - + + + Overview + + {[ + { + label: "Memories", + value: orgSummary?.documentCount, + icon: FileText, + }, + { + label: "Spaces", + value: orgSummary?.containerTagCount, + icon: Layers, + }, + { + label: "Connections", + value: orgSummary?.activeConnectors, + icon: Plug, + }, + { label: "Searches", value: searchesUsed, icon: Search }, + ].map(({ label, value, icon: Icon }) => ( + + + + + + + {typeof value === "number" ? value.toLocaleString() : "—"} + + + {label} + + + + ))} + + + - - + + - Team members + + Team members + {(org?.members?.length ?? 0) > 0 && ( + + {org?.members?.length} + + )} + Invite people into {org?.name ?? "your organization"} and manage @@ -541,17 +615,6 @@ export default function Account() { - {(org?.members?.length ?? 0) > 0 && ( - - {org?.members?.length}{" "} - {org?.members?.length === 1 ? "member" : "members"} - - )} {canManageTeam && ( - - - {!canManageTeam && ( - - - + {showTeamCard && ( + + + {!canManageTeam && ( + + + + + + Only organization owners and admins can invite teammates or + change roles. + - - Only organization owners and admins can invite teammates or - change roles. - - - )} + )} - {pendingInvitations.length > 0 && ( - - - Pending invitations - - - {pendingInvitations.map((invitation) => ( - - - - - - - {invitation.email} - - - Invited as {formatRole(invitation.role)} - - - {canManageTeam && ( - - - cancelInvitationMutation.mutate(invitation.id) - } - className="flex size-8 items-center justify-center rounded-[8px] text-[#8A5247] hover:bg-[#1A0F0C]/60 hover:text-[#C73B1B] disabled:opacity-50" - aria-label={`Cancel invitation for ${invitation.email}`} - title="Cancel invitation" + {pendingInvitations.length > 0 && ( + + + Pending invitations + + + {pendingInvitations.map((invitation) => ( + + + + + + - - + {invitation.email} + + + Invited as {formatRole(invitation.role)} + - )} - - ))} - - - )} - - {org?.members && org.members.length > 0 ? ( - - {[...org.members] - .sort((a, b) => { - const rolePriority = (r: string) => - r === "owner" ? 0 : r === "admin" ? 1 : 2 - const diff = - rolePriority(a.role.toLowerCase()) - - rolePriority(b.role.toLowerCase()) - if (diff !== 0) return diff - return (a.user?.name ?? "").localeCompare( - b.user?.name ?? "", - ) - }) - .map((m, idx) => { - const isYou = m.userId === user?.id - const memberRole = m.role.toLowerCase() - const name = m.user?.name ?? m.user?.email ?? "Unknown" - const canEditMember = - canManageTeam && !isYou && memberRole !== "owner" - return ( - 0 && "border-t border-white/[0.04]", + {canManageTeam && ( + + + cancelInvitationMutation.mutate(invitation.id) + } + className="flex size-8 items-center justify-center rounded-[8px] text-[#8A5247] hover:bg-[#1A0F0C]/60 hover:text-[#C73B1B] disabled:opacity-50" + aria-label={`Cancel invitation for ${invitation.email}`} + title="Cancel invitation" + > + + + )} - > - - - - {(name.charAt(0) || "U").toUpperCase()} - - - - - + ))} + + + )} + + {org?.members && org.members.length > 0 && ( + + {[...org.members] + .sort((a, b) => { + const rolePriority = (r: string) => + r === "owner" ? 0 : r === "admin" ? 1 : 2 + const diff = + rolePriority(a.role.toLowerCase()) - + rolePriority(b.role.toLowerCase()) + if (diff !== 0) return diff + return (a.user?.name ?? "").localeCompare( + b.user?.name ?? "", + ) + }) + .map((m) => { + const isYou = m.userId === user?.id + const memberRole = m.role.toLowerCase() + const name = m.user?.name ?? m.user?.email ?? "Unknown" + const canEditMember = + canManageTeam && !isYou && memberRole !== "owner" + return ( + + + + + {(name.charAt(0) || "U").toUpperCase()} + + + + + + {name} + + {isYou && ( + + You + )} - > - {name} - - {isYou && ( + + {m.user?.email && ( - You + {m.user.email} )} - {m.user?.email && ( - { + if (value === memberRole) return + updateMemberRoleMutation.mutate({ + memberId: m.id, + role: value as InviteRole, + }) + }} > - {m.user.email} - + + + + + Member + Admin + + + ) : ( + )} - - {canEditMember ? ( - { - if (value === memberRole) return - updateMemberRoleMutation.mutate({ - memberId: m.id, - role: value as InviteRole, - }) - }} - > - - - - - Member - Admin - - - ) : ( - - )} - {canEditMember && ( - - - - - - - - - removeMemberMutation.mutate(m.id) - } - disabled={ - removeMemberMutation.isPending || !isOwner - } - > - - Remove member - - - - )} - - ) - })} - - ) : ( - - - - - - - Just you for now - - - Invite teammates to start collaborating. - - - - )} + {canEditMember && ( + + + + + + + + + removeMemberMutation.mutate(m.id) + } + disabled={ + removeMemberMutation.isPending || !isOwner + } + > + + Remove member + + + + )} + + ) + })} + + )} + - + )} setInviteDialogOpen(false)} className={cn( dmSans125ClassName(), - "h-9 rounded-full border border-[#161F2C] bg-[#0D121A] px-4 text-[13px] font-medium text-[#737373] transition-colors hover:bg-[#14161A] hover:text-white", + "h-9 rounded-full px-4 text-[13px] font-medium text-[#737373] transition-colors hover:bg-white/[0.04] hover:text-white", )} > Cancel diff --git a/apps/web/components/settings/billing.tsx b/apps/web/components/settings/billing.tsx index 556874f87..2021f83b2 100644 --- a/apps/web/components/settings/billing.tsx +++ b/apps/web/components/settings/billing.tsx @@ -436,7 +436,7 @@ function getInvoiceProductLabel(productId: string | undefined): string { export default function Billing() { const queryClient = useQueryClient() const { user, org } = useAuth() - const autumn = useCustomer({ expand: ["payment_method"] }) + const autumn = useCustomer() const [isUpgrading, setIsUpgrading] = useState(false) const [isCancelling, setIsCancelling] = useState(false) const [isResuming, setIsResuming] = useState(false) diff --git a/apps/web/components/settings/connections-mcp.tsx b/apps/web/components/settings/connections-mcp.tsx index 08e79fa32..43bbcee2e 100644 --- a/apps/web/components/settings/connections-mcp.tsx +++ b/apps/web/components/settings/connections-mcp.tsx @@ -23,8 +23,8 @@ import { useQueryState } from "nuqs" import type { ConnectionResponseSchema } from "@repo/validation/api" import type { z } from "zod" import { analytics } from "@/lib/analytics" -import { ConnectAIModal } from "@/components/connect-ai-modal" import { AddDocumentModal } from "@/components/add-document" +import { useRouter } from "next/navigation" import { RemoveConnectionDialog } from "@/components/remove-connection-dialog" import { addDocumentParam } from "@/lib/search-params" import { DEFAULT_PROJECT_ID } from "@lib/constants" @@ -421,7 +421,7 @@ export default function ConnectionsMCP() { const queryClient = useQueryClient() const autumn = useCustomer() const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam) - const [mcpModalOpen, setMcpModalOpen] = useState(false) + const router = useRouter() const [removeDialog, setRemoveDialog] = useState<{ open: boolean connection: Connection | null @@ -709,14 +709,14 @@ export default function ConnectionsMCP() { - - setMcpModalOpen(true)}> - - - Connect your AI to Supermemory - - - + router.push("/?view=integrations&cat=ai-clients")} + > + + + Connect your AI to Supermemory + + diff --git a/apps/web/components/settings/org-context.tsx b/apps/web/components/settings/org-context.tsx index 8c17ed01d..0aeb7c9f9 100644 --- a/apps/web/components/settings/org-context.tsx +++ b/apps/web/components/settings/org-context.tsx @@ -1,70 +1,17 @@ "use client" -import { useEffect, useMemo, useState } from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { LoaderIcon, Settings, X } from "lucide-react" +import { useEffect, useState } from "react" +import { LoaderIcon } from "lucide-react" import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts" import { useOrgSettings, useUpdateOrgSettings } from "@/hooks/use-org-settings" import { cn } from "@lib/utils" -import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog" - -type ContextTemplate = { - id: string - label: string - description: string - prompt: string -} - -const SURFACE_SHADOW = - "0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset" - -const CONTEXT_TEMPLATES: ContextTemplate[] = [ - { - id: "personal-general", - label: "General Personal Assistant", - description: - "Remember preferences, routines, relationships, plans, and life context.", - prompt: `Supermemory personal assistant. The user saves conversations, notes, and daily context. - -EXTRACT: -- Preferences: "prefers morning meetings", "allergic to peanuts" -- Routines: "works out every Tuesday and Thursday" -- Relationships: "Sarah is their manager", "lives with roommate Jake" -- Plans: "planning a trip to Japan in March" -- Life events: "moved to Austin last month", "started a new job" - -SKIP: -- Generic assistant suggestions the user did not confirm -- Pleasantries and small talk without factual content -- Repeated scheduling details already captured`, - }, - { - id: "personal-productivity", - label: "Productivity Assistant", - description: - "Focus on tasks, decisions, deadlines, workflows, and blockers.", - prompt: `Supermemory productivity tool. The user saves meeting notes, task updates, and project context. - -EXTRACT: -- Action items: "needs to send proposal to client by Friday" -- Decisions: "team decided to use Figma for design handoff" -- Deadlines: "Q3 review due September 15th" -- Workflows: "deploys happen every Wednesday via CI pipeline" -- Blockers: "waiting on legal approval before launch" - -SKIP: -- Meeting filler ("let's circle back", "good point") -- Status updates that repeat previously captured information -- Agenda items with no outcome or decision`, - }, -] function SectionTitle({ children }: { children: React.ReactNode }) { return ( {children} @@ -72,55 +19,9 @@ function SectionTitle({ children }: { children: React.ReactNode }) { ) } -function SettingsCard({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ) -} - -function PillButton({ - children, - onClick, - disabled, - variant = "default", -}: { - children: React.ReactNode - onClick: () => void - disabled?: boolean - variant?: "default" | "ghost" | "primary" -}) { - return ( - - {children} - - ) -} - export function OrgContext() { const { data: settings, isLoading, isError } = useOrgSettings() const updateSettings = useUpdateOrgSettings() - const [confirmDialog, setConfirmDialog] = useState< - "enable" | "disable" | null - >(null) - const [isManaging, setIsManaging] = useState(false) const [prompt, setPrompt] = useState("") const enabled = settings?.shouldLLMFilter ?? false @@ -132,255 +33,95 @@ export function OrgContext() { setPrompt(settings?.filterPrompt ?? "") }, [settings?.filterPrompt]) - const selectedTemplateId = useMemo(() => { - const normalized = prompt.trim() - return CONTEXT_TEMPLATES.find( - (template) => template.prompt.trim() === normalized, - )?.id - }, [prompt]) - - const handleConfirmToggle = () => { - const newEnabled = confirmDialog === "enable" - updateSettings.mutate( - newEnabled - ? { shouldLLMFilter: true } - : { shouldLLMFilter: false, filterPrompt: null }, - { - onSuccess: () => { - setConfirmDialog(null) - if (!newEnabled) { - setIsManaging(false) - setPrompt("") - } - }, - }, - ) + const handleToggle = (next: boolean) => { + updateSettings.mutate({ shouldLLMFilter: next }) } const handleSave = () => { - updateSettings.mutate( - { - shouldLLMFilter: true, - filterPrompt: prompt.trim() ? prompt.trim() : null, - }, - { - onSuccess: () => { - setIsManaging(false) - }, - }, - ) - } - - const handleCancel = () => { - setPrompt(savedPrompt) - setIsManaging(false) + updateSettings.mutate({ + shouldLLMFilter: true, + filterPrompt: prompt.trim() ? prompt.trim() : null, + }) } return ( - - - Organization Context - - Guide how Nova processes and remembers your content. - - - - - - - - - {enabled ? "Context is enabled" : "Context is disabled"} - - - {enabled - ? "New content will use your guidance when Nova creates memories." - : "Turn this on to tell Nova what matters most when it learns."} - - - - - setConfirmDialog(enabled ? "disable" : "enable")} - disabled={!settingsReady || updateSettings.isPending} - variant={enabled ? "ghost" : "primary"} - > - {enabled ? "DISABLE" : "ENABLE"} - - {enabled && ( - setIsManaging(true)} - disabled={updateSettings.isPending} - > - - MANAGE - - )} - - - - {enabled && !isManaging && savedPrompt && ( - - - {savedPrompt} - - - )} - - {enabled && !isManaging && !savedPrompt && ( - - No organization context configured.{" "} - setIsManaging(true)} - className="text-[#4BA0FA] transition-colors hover:text-[#7BB8FF] cursor-pointer" - > - Set up now - - - )} - - {enabled && isManaging && ( - - - - - What should Nova focus on? - - setPrompt(event.target.value)} - placeholder="Describe what Nova should extract, skip, and prioritize when turning your content into memories..." - className={cn( - dmSansClassName(), - "min-h-[180px] w-full resize-y rounded-[14px] border border-white/[0.08] bg-[#0D121A] p-4 text-[13px] leading-relaxed text-[#FAFAFA] placeholder:text-[#525966] focus:border-white/[0.14] focus:outline-none", - )} - maxLength={750} - /> - - {prompt.length}/750 - - - - - {CONTEXT_TEMPLATES.map((template) => { - const isSelected = selectedTemplateId === template.id - return ( - setPrompt(template.prompt)} - className={cn( - "rounded-[14px] border p-4 text-left transition-colors cursor-pointer", - "bg-[#14161A] hover:bg-[#121820]", - isSelected - ? "border-white/[0.16] bg-[#171B22]" - : "border-white/[0.08]", - )} - > - - {template.label} - - - {template.description} - - - ) - })} - - - - - - CANCEL - - - {updateSettings.isPending && ( - - )} - SAVE - - - - )} + + + + Organization Context + + Guide how Supermemory processes and remembers your content. + - - - { - if (!open) setConfirmDialog(null) - }} - > - handleToggle(!enabled)} className={cn( - "sm:max-w-[440px] border border-white/[0.12] bg-[#1B1F24] p-0 gap-0 rounded-[22px] overflow-hidden", dmSansClassName(), + "inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-full px-4 text-[12px] font-semibold transition-[color,opacity] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50", + enabled + ? "text-[#737373] hover:bg-white/[0.04] hover:text-[#A3A3A3]" + : "bg-[#0D121A] text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] hover:opacity-80", )} > - - - - + )} + {enabled ? "DISABLE" : "ENABLE"} + + + + {enabled && ( + + setPrompt(event.target.value)} + placeholder="Tell Nova what to focus on, extract, and skip when turning your content into memories..." + maxLength={750} + className="min-h-[96px] w-full resize-y rounded-[12px] border border-white/[0.08] bg-[#0D121A] px-3.5 py-3 text-[13px] leading-relaxed text-[#FAFAFA] placeholder:text-[#525966] focus:border-white/[0.16] focus:outline-none" + /> + + + {prompt.length}/750 + + {dirty && ( + + setPrompt(savedPrompt)} + disabled={updateSettings.isPending} className={cn( - dmSans125ClassName(), - "text-[18px] font-semibold tracking-[-0.18px] text-[#FAFAFA]", + dmSansClassName(), + "h-7 rounded-full px-3 text-[12px] font-medium text-[#737373] transition-colors hover:text-[#A3A3A3] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50", )} > - {confirmDialog === "enable" - ? "Enable Organization Context?" - : "Disable Organization Context?"} - - + - {confirmDialog === "enable" - ? "Nova will use your guidance when processing new content across this organization." - : "Nova will stop using this guidance for new content, and the saved context will be cleared."} - + {updateSettings.isPending && ( + + )} + Save + - - - Close - - - - setConfirmDialog(null)} - variant="ghost" - > - CANCEL - - - {updateSettings.isPending && ( - - )} - {confirmDialog === "enable" ? "ENABLE" : "DISABLE"} - - + )} - - + + )} ) } diff --git a/apps/web/components/settings/settings-content.tsx b/apps/web/components/settings/settings-content.tsx new file mode 100644 index 000000000..cb3d5e820 --- /dev/null +++ b/apps/web/components/settings/settings-content.tsx @@ -0,0 +1,541 @@ +"use client" + +import { Logo } from "@ui/assets/Logo" +import { useAuth } from "@lib/auth-context" +import NovaOrb from "@/components/nova/nova-orb" +import { useState } from "react" +import { cn } from "@lib/utils" +import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" +import Account from "@/components/settings/account" +import Billing from "@/components/settings/billing" +import Integrations from "@/components/settings/integrations" +import ConnectionsMCP from "@/components/settings/connections-mcp" +import Support from "@/components/settings/support" +import { ErrorBoundary } from "@/components/error-boundary" +import { useRouter } from "next/navigation" +import { useIsMobile } from "@hooks/use-mobile" +import { useLocalStorageUsername } from "@hooks/use-local-storage-username" +import { + LogOut, + RotateCcw, + Trash2, + Sun, + LoaderIcon, + User as UserIcon, + Zap, + HelpCircle, + CreditCard, + ShieldAlert, + ChevronRight, + ArrowUpRight, +} from "lucide-react" +import { authClient } from "@lib/auth" +import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog" +import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover" +import { useResetOrganization } from "@/hooks/use-reset-organization" +import { useDeleteUserAccount } from "@/hooks/use-account-settings" +import { SettingsOrgSwitcher } from "@/components/settings/settings-org-switcher" + +export const TABS = [ + "account", + "billing", + "integrations", + "connections", + "support", +] as const +export type SettingsTab = (typeof TABS)[number] + +type NavItem = { + id: SettingsTab + label: string + description: string + icon: React.ReactNode +} + +const NAV_ITEMS: NavItem[] = [ + { + id: "account", + label: "Account", + description: "Your profile and organization", + icon: , + }, + { + id: "billing", + label: "Billing", + description: "Plan, usage and payments", + icon: , + }, + { + id: "integrations", + label: "Integrations", + description: "Save, sync and search across tools", + icon: , + }, + { + id: "connections", + label: "Connections & MCP", + description: "Drive, Notion, OneDrive, MCP", + icon: , + }, + { + id: "support", + label: "Support & Help", + description: "Get help or share feedback", + icon: , + }, +] + +export function parseHashToTab(hash: string): SettingsTab { + const cleaned = hash.replace("#", "").toLowerCase() + return TABS.includes(cleaned as SettingsTab) + ? (cleaned as SettingsTab) + : "account" +} + +function IdentityCard({ displayName }: { displayName: string }) { + const firstName = displayName?.split(" ")[0] || "" + + return ( + + + + + + + {firstName ? `${firstName}'s` : "Your"} + + + supermemory + + + + + ) +} + +export function SettingsContent({ + activeTab, + onTabChange, + className, + showIdentity = true, + onClose, +}: { + activeTab: SettingsTab + onTabChange: (tab: SettingsTab) => void + className?: string + showIdentity?: boolean + onClose?: () => void +}) { + const { user, org } = useAuth() + const router = useRouter() + const isMobile = useIsMobile() + const localStorageUsername = useLocalStorageUsername() + + const [isResetDialogOpen, setIsResetDialogOpen] = useState(false) + const [resetConfirmation, setResetConfirmation] = useState("") + const resetOrganization = useResetOrganization() + + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) + const [deleteEmailConfirm, setDeleteEmailConfirm] = useState("") + const deleteUserAccount = useDeleteUserAccount() + + const [dangerMenuOpen, setDangerMenuOpen] = useState(false) + + const displayName = + user?.displayUsername || + localStorageUsername || + user?.name || + user?.email?.split("@")[0] || + "" + + const handleLogout = async () => { + await authClient.signOut() + router.push("/login") + } + + const handleIntegrations = () => { + router.push("/?view=integrations") + onClose?.() + } + + const handleDeleteAccount = async () => { + if (deleteEmailConfirm !== user?.email) return + deleteUserAccount.mutate( + { confirmation: deleteEmailConfirm }, + { + onSuccess: () => { + setIsDeleteDialogOpen(false) + setDeleteEmailConfirm("") + router.push("/login") + }, + }, + ) + } + + return ( + <> + + {/* Left rail */} + + + {/* Content */} + + + Something went wrong loading this section.{" "} + window.location.reload()} + > + Reload + + + } + > + {activeTab === "account" && } + {activeTab === "billing" && } + {activeTab === "integrations" && } + {activeTab === "connections" && } + {activeTab === "support" && } + + + + + {/* Reset data dialog */} + {(() => { + const confirmText = org?.name || user?.name || "" + return ( + { + setIsResetDialogOpen(open) + if (!open) setResetConfirmation("") + }} + > + + + + + Reset all data? + + + This permanently removes: + + + All documents and memories + All connections (Google Drive, Notion, etc.) + All custom spaces (default space stays) + Organization settings and filters + + + Your account and billing plan stay intact.{" "} + + This cannot be undone. + + + + + + Type{" "} + + {confirmText || "your name"} + {" "} + to confirm: + + setResetConfirmation(e.target.value)} + placeholder={confirmText || "Your name"} + autoComplete="off" + className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C7991B]/50 transition-colors" + /> + + + + + Cancel + + + + resetOrganization.mutate( + { confirmation: confirmText }, + { + onSuccess: () => { + setIsResetDialogOpen(false) + setResetConfirmation("") + }, + }, + ) + } + className="flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#1A1200] text-[#C7991B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90" + > + {resetOrganization.isPending ? ( + + ) : ( + + )} + {resetOrganization.isPending + ? "Resetting…" + : "Reset organization"} + + + + + + ) + })()} + + {/* Delete account dialog */} + { + setIsDeleteDialogOpen(open) + if (!open) setDeleteEmailConfirm("") + }} + > + + + + + Delete your account? + + + Permanently deletes all your data and cancels any active + subscriptions.{" "} + + This cannot be undone. + + + + + + Type your email{" "} + {user?.email} to + confirm: + + setDeleteEmailConfirm(e.target.value)} + placeholder={user?.email ?? "your@email.com"} + className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C73B1B]/50 transition-colors" + /> + + + + + Cancel + + + + {deleteUserAccount.isPending ? ( + + ) : ( + + )} + {deleteUserAccount.isPending ? "Deleting…" : "Delete account"} + + + + + + > + ) +} diff --git a/apps/web/components/settings/settings-modal.tsx b/apps/web/components/settings/settings-modal.tsx new file mode 100644 index 000000000..ee4a57843 --- /dev/null +++ b/apps/web/components/settings/settings-modal.tsx @@ -0,0 +1,143 @@ +"use client" + +import { + createContext, + useCallback, + useContext, + useMemo, + type ReactNode, +} from "react" +import { useQueryState } from "nuqs" +import { + Dialog, + DialogClose, + DialogContent, + DialogTitle, +} from "@ui/components/dialog" +import { X } from "lucide-react" +import { cn } from "@lib/utils" +import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" +import { analytics } from "@/lib/analytics" +import { + SettingsContent, + TABS, + type SettingsTab, +} from "@/components/settings/settings-content" + +const SETTINGS_PARAM = "settings" + +/** Integrations is a link-out, not a modal tab, so it falls back to account. */ +function parseTab(value: string | null): SettingsTab { + if ( + value && + value !== "integrations" && + TABS.includes(value as SettingsTab) + ) { + return value as SettingsTab + } + return "account" +} + +type SettingsModalContextValue = { + open: boolean + openSettings: (tab?: SettingsTab) => void + close: () => void +} + +const SettingsModalContext = createContext( + null, +) + +export function SettingsModalProvider({ children }: { children: ReactNode }) { + const [param, setParam] = useQueryState(SETTINGS_PARAM) + const open = param !== null + const tab = parseTab(param) + + const openSettings = useCallback( + (next?: SettingsTab) => { + setParam(next ?? "account") + }, + [setParam], + ) + + const close = useCallback(() => setParam(null), [setParam]) + + const handleTabChange = useCallback( + (next: SettingsTab) => { + setParam(next) + analytics.settingsTabChanged({ tab: next }) + }, + [setParam], + ) + + const value = useMemo( + () => ({ open, openSettings, close }), + [open, openSettings, close], + ) + + return ( + + {children} + { + if (!next) setParam(null) + }} + > + + + + Settings + + + + Close + + + + setParam(null)} + className="flex-1 min-h-0 w-full overflow-y-auto md:overflow-hidden px-5 md:px-4 pt-4 pb-6" + /> + + + + ) +} + +export function useSettingsModal() { + const ctx = useContext(SettingsModalContext) + if (!ctx) { + throw new Error( + "useSettingsModal must be used within a SettingsModalProvider", + ) + } + return ctx +} diff --git a/apps/web/components/settings/settings-org-switcher.tsx b/apps/web/components/settings/settings-org-switcher.tsx new file mode 100644 index 000000000..9ebcf9b52 --- /dev/null +++ b/apps/web/components/settings/settings-org-switcher.tsx @@ -0,0 +1,262 @@ +"use client" + +import { useMemo, useState } from "react" +import { useCustomer } from "autumn-js/react" +import { toast } from "sonner" +import { + Building2, + Check, + ChevronsUpDown, + LoaderIcon, + Plus, +} from "lucide-react" +import { cn } from "@lib/utils" +import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" +import { useAuth } from "@lib/auth-context" +import { authClient } from "@lib/auth" +import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover" +import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog" +import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge" +import { useOrgSummaries } from "@/hooks/use-org-summaries" +import { useTokenUsage, type PlanType } from "@/hooks/use-token-usage" + +const SURFACE_SHADOW = + "0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset" + +function generateOrgSlug(name: string): string { + const base = + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") || "org" + return `${base}-${Math.floor(100000 + Math.random() * 900000)}` +} + +export function SettingsOrgSwitcher() { + const { org, organizations, setActiveOrg } = useAuth() + const autumn = useCustomer() + const { currentPlan } = useTokenUsage(autumn) + const { data: orgSummaries } = useOrgSummaries() + + const [open, setOpen] = useState(false) + const [switchingId, setSwitchingId] = useState(null) + const [createOpen, setCreateOpen] = useState(false) + const [createName, setCreateName] = useState("") + const [creating, setCreating] = useState(false) + + const planByOrgId = useMemo(() => { + const map = new Map() + for (const summary of orgSummaries ?? []) { + map.set(summary.orgId, summary.plan) + } + return map + }, [orgSummaries]) + + const activeOrgPlan = org?.id + ? resolveOrgPlan(org.id, true, currentPlan, planByOrgId) + : currentPlan + + const sortedOrgs = useMemo( + () => + [...(organizations ?? [])].sort((a, b) => a.name.localeCompare(b.name)), + [organizations], + ) + + const handleSwitch = async (slug: string, id: string) => { + if (id === org?.id) { + setOpen(false) + return + } + setSwitchingId(id) + try { + await setActiveOrg(slug) + window.location.reload() + } catch (error) { + console.error("Failed to switch organization:", error) + setSwitchingId(null) + toast.error("Failed to switch organization") + } + } + + const handleCreate = async () => { + const name = createName.trim() + if (!name || creating) return + setCreating(true) + try { + const result = await authClient.organization.create({ + name, + slug: generateOrgSlug(name), + metadata: { signupSource: "consumer" }, + }) + if (result.error) { + throw new Error(result.error.message ?? "Failed to create organization") + } + await setActiveOrg(result.data?.slug ?? "") + window.location.reload() + } catch (error) { + setCreating(false) + toast.error( + error instanceof Error + ? error.message + : "Failed to create organization", + ) + } + } + + return ( + <> + + + + + + + + {org?.name ?? "Personal"} + + + + + + + {sortedOrgs.map((organization) => { + const isCurrent = organization.id === org?.id + const isSwitching = switchingId === organization.id + const plan = resolveOrgPlan( + organization.id, + isCurrent, + currentPlan, + planByOrgId, + ) + return ( + handleSwitch(organization.slug, organization.id)} + className={cn( + "w-full flex items-center gap-2.5 rounded-[10px] px-3 py-2 text-left transition-colors", + isCurrent ? "bg-white/5" : "hover:bg-white/5 cursor-pointer", + "disabled:cursor-default", + )} + > + + + {organization.name} + + {isSwitching ? ( + + ) : isCurrent ? ( + + ) : null} + + + ) + })} + + + + { + setOpen(false) + setCreateOpen(true) + }} + className="w-full flex items-center gap-2.5 rounded-[10px] px-3 py-2 text-left text-[#A3A3A3] transition-colors hover:bg-white/5 hover:text-white cursor-pointer" + > + + + Create organization + + + + + + { + setCreateOpen(next) + if (!next) setCreateName("") + }} + > + + + + + Create organization + + + A separate workspace with its own memories, connections, and + members. + + + setCreateName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate() + }} + placeholder="Organization name" + maxLength={80} + className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#4BA0FA]/50 transition-colors" + /> + + setCreateOpen(false)} + className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer" + > + Cancel + + + {creating ? ( + + ) : ( + + )} + {creating ? "Creating…" : "Create"} + + + + + + > + ) +} diff --git a/apps/web/components/user-profile-menu.tsx b/apps/web/components/user-profile-menu.tsx index 632d015b2..77593fb83 100644 --- a/apps/web/components/user-profile-menu.tsx +++ b/apps/web/components/user-profile-menu.tsx @@ -17,6 +17,7 @@ import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" import { useOrgOnboarding } from "@hooks/use-org-onboarding" import { useTokenUsage } from "@/hooks/use-token-usage" +import { useSettingsModal } from "@/components/settings/settings-modal" export function UserProfileMenu({ className, @@ -29,6 +30,7 @@ export function UserProfileMenu({ }) { const { user } = useAuth() const router = useRouter() + const { openSettings } = useSettingsModal() const { resetOrgOnboarded } = useOrgOnboarding() const autumn = useCustomer() const { currentPlan, isLoading: planLoading } = useTokenUsage(autumn) @@ -144,7 +146,7 @@ export function UserProfileMenu({ router.push("/settings")} + onClick={() => openSettings()} className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer" >
- {firstName ? `${firstName}'s` : "Your"} -
- supermemory -
- This permanently removes: -
- Your account and billing plan stay intact.{" "} - - This cannot be undone. - -
- Type{" "} - - {confirmText || "your name"} - {" "} - to confirm: -
- Permanently deletes all your data and cancels any active - subscriptions.{" "} - - This cannot be undone. - -
- Type your email{" "} - {user?.email} to - confirm: -
{children} @@ -127,6 +132,10 @@ function isPendingInvitation(invitation: { export default function Account() { const { user, org, refetchActiveOrg, refetchOrganizations } = useAuth() + const autumn = useCustomer() + const { currentPlan, searchesUsed } = useTokenUsage(autumn) + const { data: orgSummaries } = useOrgSummaries() + const orgSummary = orgSummaries?.find((s) => s.orgId === org?.id) const [inviteDialogOpen, setInviteDialogOpen] = useState(false) const [inviteEmail, setInviteEmail] = useState("") const [inviteRole, setInviteRole] = useState("member") @@ -202,6 +211,11 @@ export default function Account() { [org?.invitations], ) + const showTeamCard = + !canManageTeam || + pendingInvitations.length > 0 || + (org?.members?.length ?? 0) > 1 + const resetInviteForm = () => { setInviteEmail("") setInviteRole("member") @@ -372,30 +386,29 @@ export default function Account() { : "—" return ( - - - Profile Details + + - - {/* Avatar + Name/Email */} - - + + {/* Identity */} + + - + {user?.name?.charAt(0) ?? "U"} - + {user?.name ?? "—"} @@ -403,28 +416,32 @@ export default function Account() { {user?.email ?? "—"} + + + - - - + + Organization - + {isEditingOrgName ? ( - - - {updateOrgNameMutation.isPending ? ( - - ) : null} - Save - - { - setOrgNameDraft(org?.name ?? "") - setIsEditingOrgName(false) - }} - className={cn( - "inline-flex size-8 items-center justify-center rounded-full bg-[#0D121A] text-[#737373] shadow-inside-out transition-colors hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-50", - )} - > - - - + + {updateOrgNameMutation.isPending ? ( + + ) : null} + Save + + { + setOrgNameDraft(org?.name ?? "") + setIsEditingOrgName(false) + }} + className="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#737373] shadow-inside-out transition-colors hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-50" + > + + ) : ( - + {org?.name ?? "Personal"} @@ -491,49 +504,110 @@ export default function Account() { setOrgNameDraft(org?.name ?? "") setIsEditingOrgName(true) }} - className={cn( - "inline-flex size-7 shrink-0 items-center justify-center rounded-md text-[#FAFAFA] transition-colors hover:bg-white/5", - )} + className="inline-flex size-5 shrink-0 items-center justify-center rounded-md text-[#737373] transition-colors hover:bg-white/5 hover:text-[#FAFAFA]" > - + ) : null} )} - - + Member since - - + {memberSince} - + + + Overview + + {[ + { + label: "Memories", + value: orgSummary?.documentCount, + icon: FileText, + }, + { + label: "Spaces", + value: orgSummary?.containerTagCount, + icon: Layers, + }, + { + label: "Connections", + value: orgSummary?.activeConnectors, + icon: Plug, + }, + { label: "Searches", value: searchesUsed, icon: Search }, + ].map(({ label, value, icon: Icon }) => ( + + + + + + + {typeof value === "number" ? value.toLocaleString() : "—"} + + + {label} + + + + ))} + + + - - + + - Team members + + Team members + {(org?.members?.length ?? 0) > 0 && ( + + {org?.members?.length} + + )} + Invite people into {org?.name ?? "your organization"} and manage @@ -541,17 +615,6 @@ export default function Account() { - {(org?.members?.length ?? 0) > 0 && ( - - {org?.members?.length}{" "} - {org?.members?.length === 1 ? "member" : "members"} - - )} {canManageTeam && ( - - - {!canManageTeam && ( - - - + {showTeamCard && ( + + + {!canManageTeam && ( + + + + + + Only organization owners and admins can invite teammates or + change roles. + - - Only organization owners and admins can invite teammates or - change roles. - - - )} + )} - {pendingInvitations.length > 0 && ( - - - Pending invitations - - - {pendingInvitations.map((invitation) => ( - - - - - - - {invitation.email} - - - Invited as {formatRole(invitation.role)} - - - {canManageTeam && ( - - - cancelInvitationMutation.mutate(invitation.id) - } - className="flex size-8 items-center justify-center rounded-[8px] text-[#8A5247] hover:bg-[#1A0F0C]/60 hover:text-[#C73B1B] disabled:opacity-50" - aria-label={`Cancel invitation for ${invitation.email}`} - title="Cancel invitation" + {pendingInvitations.length > 0 && ( + + + Pending invitations + + + {pendingInvitations.map((invitation) => ( + + + + + + - - + {invitation.email} + + + Invited as {formatRole(invitation.role)} + - )} - - ))} - - - )} - - {org?.members && org.members.length > 0 ? ( - - {[...org.members] - .sort((a, b) => { - const rolePriority = (r: string) => - r === "owner" ? 0 : r === "admin" ? 1 : 2 - const diff = - rolePriority(a.role.toLowerCase()) - - rolePriority(b.role.toLowerCase()) - if (diff !== 0) return diff - return (a.user?.name ?? "").localeCompare( - b.user?.name ?? "", - ) - }) - .map((m, idx) => { - const isYou = m.userId === user?.id - const memberRole = m.role.toLowerCase() - const name = m.user?.name ?? m.user?.email ?? "Unknown" - const canEditMember = - canManageTeam && !isYou && memberRole !== "owner" - return ( - 0 && "border-t border-white/[0.04]", + {canManageTeam && ( + + + cancelInvitationMutation.mutate(invitation.id) + } + className="flex size-8 items-center justify-center rounded-[8px] text-[#8A5247] hover:bg-[#1A0F0C]/60 hover:text-[#C73B1B] disabled:opacity-50" + aria-label={`Cancel invitation for ${invitation.email}`} + title="Cancel invitation" + > + + + )} - > - - - - {(name.charAt(0) || "U").toUpperCase()} - - - - - + ))} + + + )} + + {org?.members && org.members.length > 0 && ( + + {[...org.members] + .sort((a, b) => { + const rolePriority = (r: string) => + r === "owner" ? 0 : r === "admin" ? 1 : 2 + const diff = + rolePriority(a.role.toLowerCase()) - + rolePriority(b.role.toLowerCase()) + if (diff !== 0) return diff + return (a.user?.name ?? "").localeCompare( + b.user?.name ?? "", + ) + }) + .map((m) => { + const isYou = m.userId === user?.id + const memberRole = m.role.toLowerCase() + const name = m.user?.name ?? m.user?.email ?? "Unknown" + const canEditMember = + canManageTeam && !isYou && memberRole !== "owner" + return ( + + + + + {(name.charAt(0) || "U").toUpperCase()} + + + + + + {name} + + {isYou && ( + + You + )} - > - {name} - - {isYou && ( + + {m.user?.email && ( - You + {m.user.email} )} - {m.user?.email && ( - { + if (value === memberRole) return + updateMemberRoleMutation.mutate({ + memberId: m.id, + role: value as InviteRole, + }) + }} > - {m.user.email} - + + + + + Member + Admin + + + ) : ( + )} - - {canEditMember ? ( - { - if (value === memberRole) return - updateMemberRoleMutation.mutate({ - memberId: m.id, - role: value as InviteRole, - }) - }} - > - - - - - Member - Admin - - - ) : ( - - )} - {canEditMember && ( - - - - - - - - - removeMemberMutation.mutate(m.id) - } - disabled={ - removeMemberMutation.isPending || !isOwner - } - > - - Remove member - - - - )} - - ) - })} - - ) : ( - - - - - - - Just you for now - - - Invite teammates to start collaborating. - - - - )} + {canEditMember && ( + + + + + + + + + removeMemberMutation.mutate(m.id) + } + disabled={ + removeMemberMutation.isPending || !isOwner + } + > + + Remove member + + + + )} + + ) + })} + + )} + - + )} setInviteDialogOpen(false)} className={cn( dmSans125ClassName(), - "h-9 rounded-full border border-[#161F2C] bg-[#0D121A] px-4 text-[13px] font-medium text-[#737373] transition-colors hover:bg-[#14161A] hover:text-white", + "h-9 rounded-full px-4 text-[13px] font-medium text-[#737373] transition-colors hover:bg-white/[0.04] hover:text-white", )} > Cancel diff --git a/apps/web/components/settings/billing.tsx b/apps/web/components/settings/billing.tsx index 556874f87..2021f83b2 100644 --- a/apps/web/components/settings/billing.tsx +++ b/apps/web/components/settings/billing.tsx @@ -436,7 +436,7 @@ function getInvoiceProductLabel(productId: string | undefined): string { export default function Billing() { const queryClient = useQueryClient() const { user, org } = useAuth() - const autumn = useCustomer({ expand: ["payment_method"] }) + const autumn = useCustomer() const [isUpgrading, setIsUpgrading] = useState(false) const [isCancelling, setIsCancelling] = useState(false) const [isResuming, setIsResuming] = useState(false) diff --git a/apps/web/components/settings/connections-mcp.tsx b/apps/web/components/settings/connections-mcp.tsx index 08e79fa32..43bbcee2e 100644 --- a/apps/web/components/settings/connections-mcp.tsx +++ b/apps/web/components/settings/connections-mcp.tsx @@ -23,8 +23,8 @@ import { useQueryState } from "nuqs" import type { ConnectionResponseSchema } from "@repo/validation/api" import type { z } from "zod" import { analytics } from "@/lib/analytics" -import { ConnectAIModal } from "@/components/connect-ai-modal" import { AddDocumentModal } from "@/components/add-document" +import { useRouter } from "next/navigation" import { RemoveConnectionDialog } from "@/components/remove-connection-dialog" import { addDocumentParam } from "@/lib/search-params" import { DEFAULT_PROJECT_ID } from "@lib/constants" @@ -421,7 +421,7 @@ export default function ConnectionsMCP() { const queryClient = useQueryClient() const autumn = useCustomer() const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam) - const [mcpModalOpen, setMcpModalOpen] = useState(false) + const router = useRouter() const [removeDialog, setRemoveDialog] = useState<{ open: boolean connection: Connection | null @@ -709,14 +709,14 @@ export default function ConnectionsMCP() { - - setMcpModalOpen(true)}> - - - Connect your AI to Supermemory - - - + router.push("/?view=integrations&cat=ai-clients")} + > + + + Connect your AI to Supermemory + + diff --git a/apps/web/components/settings/org-context.tsx b/apps/web/components/settings/org-context.tsx index 8c17ed01d..0aeb7c9f9 100644 --- a/apps/web/components/settings/org-context.tsx +++ b/apps/web/components/settings/org-context.tsx @@ -1,70 +1,17 @@ "use client" -import { useEffect, useMemo, useState } from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { LoaderIcon, Settings, X } from "lucide-react" +import { useEffect, useState } from "react" +import { LoaderIcon } from "lucide-react" import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts" import { useOrgSettings, useUpdateOrgSettings } from "@/hooks/use-org-settings" import { cn } from "@lib/utils" -import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog" - -type ContextTemplate = { - id: string - label: string - description: string - prompt: string -} - -const SURFACE_SHADOW = - "0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset" - -const CONTEXT_TEMPLATES: ContextTemplate[] = [ - { - id: "personal-general", - label: "General Personal Assistant", - description: - "Remember preferences, routines, relationships, plans, and life context.", - prompt: `Supermemory personal assistant. The user saves conversations, notes, and daily context. - -EXTRACT: -- Preferences: "prefers morning meetings", "allergic to peanuts" -- Routines: "works out every Tuesday and Thursday" -- Relationships: "Sarah is their manager", "lives with roommate Jake" -- Plans: "planning a trip to Japan in March" -- Life events: "moved to Austin last month", "started a new job" - -SKIP: -- Generic assistant suggestions the user did not confirm -- Pleasantries and small talk without factual content -- Repeated scheduling details already captured`, - }, - { - id: "personal-productivity", - label: "Productivity Assistant", - description: - "Focus on tasks, decisions, deadlines, workflows, and blockers.", - prompt: `Supermemory productivity tool. The user saves meeting notes, task updates, and project context. - -EXTRACT: -- Action items: "needs to send proposal to client by Friday" -- Decisions: "team decided to use Figma for design handoff" -- Deadlines: "Q3 review due September 15th" -- Workflows: "deploys happen every Wednesday via CI pipeline" -- Blockers: "waiting on legal approval before launch" - -SKIP: -- Meeting filler ("let's circle back", "good point") -- Status updates that repeat previously captured information -- Agenda items with no outcome or decision`, - }, -] function SectionTitle({ children }: { children: React.ReactNode }) { return ( {children} @@ -72,55 +19,9 @@ function SectionTitle({ children }: { children: React.ReactNode }) { ) } -function SettingsCard({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ) -} - -function PillButton({ - children, - onClick, - disabled, - variant = "default", -}: { - children: React.ReactNode - onClick: () => void - disabled?: boolean - variant?: "default" | "ghost" | "primary" -}) { - return ( - - {children} - - ) -} - export function OrgContext() { const { data: settings, isLoading, isError } = useOrgSettings() const updateSettings = useUpdateOrgSettings() - const [confirmDialog, setConfirmDialog] = useState< - "enable" | "disable" | null - >(null) - const [isManaging, setIsManaging] = useState(false) const [prompt, setPrompt] = useState("") const enabled = settings?.shouldLLMFilter ?? false @@ -132,255 +33,95 @@ export function OrgContext() { setPrompt(settings?.filterPrompt ?? "") }, [settings?.filterPrompt]) - const selectedTemplateId = useMemo(() => { - const normalized = prompt.trim() - return CONTEXT_TEMPLATES.find( - (template) => template.prompt.trim() === normalized, - )?.id - }, [prompt]) - - const handleConfirmToggle = () => { - const newEnabled = confirmDialog === "enable" - updateSettings.mutate( - newEnabled - ? { shouldLLMFilter: true } - : { shouldLLMFilter: false, filterPrompt: null }, - { - onSuccess: () => { - setConfirmDialog(null) - if (!newEnabled) { - setIsManaging(false) - setPrompt("") - } - }, - }, - ) + const handleToggle = (next: boolean) => { + updateSettings.mutate({ shouldLLMFilter: next }) } const handleSave = () => { - updateSettings.mutate( - { - shouldLLMFilter: true, - filterPrompt: prompt.trim() ? prompt.trim() : null, - }, - { - onSuccess: () => { - setIsManaging(false) - }, - }, - ) - } - - const handleCancel = () => { - setPrompt(savedPrompt) - setIsManaging(false) + updateSettings.mutate({ + shouldLLMFilter: true, + filterPrompt: prompt.trim() ? prompt.trim() : null, + }) } return ( - - - Organization Context - - Guide how Nova processes and remembers your content. - - - - - - - - - {enabled ? "Context is enabled" : "Context is disabled"} - - - {enabled - ? "New content will use your guidance when Nova creates memories." - : "Turn this on to tell Nova what matters most when it learns."} - - - - - setConfirmDialog(enabled ? "disable" : "enable")} - disabled={!settingsReady || updateSettings.isPending} - variant={enabled ? "ghost" : "primary"} - > - {enabled ? "DISABLE" : "ENABLE"} - - {enabled && ( - setIsManaging(true)} - disabled={updateSettings.isPending} - > - - MANAGE - - )} - - - - {enabled && !isManaging && savedPrompt && ( - - - {savedPrompt} - - - )} - - {enabled && !isManaging && !savedPrompt && ( - - No organization context configured.{" "} - setIsManaging(true)} - className="text-[#4BA0FA] transition-colors hover:text-[#7BB8FF] cursor-pointer" - > - Set up now - - - )} - - {enabled && isManaging && ( - - - - - What should Nova focus on? - - setPrompt(event.target.value)} - placeholder="Describe what Nova should extract, skip, and prioritize when turning your content into memories..." - className={cn( - dmSansClassName(), - "min-h-[180px] w-full resize-y rounded-[14px] border border-white/[0.08] bg-[#0D121A] p-4 text-[13px] leading-relaxed text-[#FAFAFA] placeholder:text-[#525966] focus:border-white/[0.14] focus:outline-none", - )} - maxLength={750} - /> - - {prompt.length}/750 - - - - - {CONTEXT_TEMPLATES.map((template) => { - const isSelected = selectedTemplateId === template.id - return ( - setPrompt(template.prompt)} - className={cn( - "rounded-[14px] border p-4 text-left transition-colors cursor-pointer", - "bg-[#14161A] hover:bg-[#121820]", - isSelected - ? "border-white/[0.16] bg-[#171B22]" - : "border-white/[0.08]", - )} - > - - {template.label} - - - {template.description} - - - ) - })} - - - - - - CANCEL - - - {updateSettings.isPending && ( - - )} - SAVE - - - - )} + + + + Organization Context + + Guide how Supermemory processes and remembers your content. + - - - { - if (!open) setConfirmDialog(null) - }} - > - handleToggle(!enabled)} className={cn( - "sm:max-w-[440px] border border-white/[0.12] bg-[#1B1F24] p-0 gap-0 rounded-[22px] overflow-hidden", dmSansClassName(), + "inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-full px-4 text-[12px] font-semibold transition-[color,opacity] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50", + enabled + ? "text-[#737373] hover:bg-white/[0.04] hover:text-[#A3A3A3]" + : "bg-[#0D121A] text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] hover:opacity-80", )} > - - - - + )} + {enabled ? "DISABLE" : "ENABLE"} + + + + {enabled && ( + + setPrompt(event.target.value)} + placeholder="Tell Nova what to focus on, extract, and skip when turning your content into memories..." + maxLength={750} + className="min-h-[96px] w-full resize-y rounded-[12px] border border-white/[0.08] bg-[#0D121A] px-3.5 py-3 text-[13px] leading-relaxed text-[#FAFAFA] placeholder:text-[#525966] focus:border-white/[0.16] focus:outline-none" + /> + + + {prompt.length}/750 + + {dirty && ( + + setPrompt(savedPrompt)} + disabled={updateSettings.isPending} className={cn( - dmSans125ClassName(), - "text-[18px] font-semibold tracking-[-0.18px] text-[#FAFAFA]", + dmSansClassName(), + "h-7 rounded-full px-3 text-[12px] font-medium text-[#737373] transition-colors hover:text-[#A3A3A3] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50", )} > - {confirmDialog === "enable" - ? "Enable Organization Context?" - : "Disable Organization Context?"} - - + - {confirmDialog === "enable" - ? "Nova will use your guidance when processing new content across this organization." - : "Nova will stop using this guidance for new content, and the saved context will be cleared."} - + {updateSettings.isPending && ( + + )} + Save + - - - Close - - - - setConfirmDialog(null)} - variant="ghost" - > - CANCEL - - - {updateSettings.isPending && ( - - )} - {confirmDialog === "enable" ? "ENABLE" : "DISABLE"} - - + )} - - + + )} ) } diff --git a/apps/web/components/settings/settings-content.tsx b/apps/web/components/settings/settings-content.tsx new file mode 100644 index 000000000..cb3d5e820 --- /dev/null +++ b/apps/web/components/settings/settings-content.tsx @@ -0,0 +1,541 @@ +"use client" + +import { Logo } from "@ui/assets/Logo" +import { useAuth } from "@lib/auth-context" +import NovaOrb from "@/components/nova/nova-orb" +import { useState } from "react" +import { cn } from "@lib/utils" +import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" +import Account from "@/components/settings/account" +import Billing from "@/components/settings/billing" +import Integrations from "@/components/settings/integrations" +import ConnectionsMCP from "@/components/settings/connections-mcp" +import Support from "@/components/settings/support" +import { ErrorBoundary } from "@/components/error-boundary" +import { useRouter } from "next/navigation" +import { useIsMobile } from "@hooks/use-mobile" +import { useLocalStorageUsername } from "@hooks/use-local-storage-username" +import { + LogOut, + RotateCcw, + Trash2, + Sun, + LoaderIcon, + User as UserIcon, + Zap, + HelpCircle, + CreditCard, + ShieldAlert, + ChevronRight, + ArrowUpRight, +} from "lucide-react" +import { authClient } from "@lib/auth" +import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog" +import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover" +import { useResetOrganization } from "@/hooks/use-reset-organization" +import { useDeleteUserAccount } from "@/hooks/use-account-settings" +import { SettingsOrgSwitcher } from "@/components/settings/settings-org-switcher" + +export const TABS = [ + "account", + "billing", + "integrations", + "connections", + "support", +] as const +export type SettingsTab = (typeof TABS)[number] + +type NavItem = { + id: SettingsTab + label: string + description: string + icon: React.ReactNode +} + +const NAV_ITEMS: NavItem[] = [ + { + id: "account", + label: "Account", + description: "Your profile and organization", + icon: , + }, + { + id: "billing", + label: "Billing", + description: "Plan, usage and payments", + icon: , + }, + { + id: "integrations", + label: "Integrations", + description: "Save, sync and search across tools", + icon: , + }, + { + id: "connections", + label: "Connections & MCP", + description: "Drive, Notion, OneDrive, MCP", + icon: , + }, + { + id: "support", + label: "Support & Help", + description: "Get help or share feedback", + icon: , + }, +] + +export function parseHashToTab(hash: string): SettingsTab { + const cleaned = hash.replace("#", "").toLowerCase() + return TABS.includes(cleaned as SettingsTab) + ? (cleaned as SettingsTab) + : "account" +} + +function IdentityCard({ displayName }: { displayName: string }) { + const firstName = displayName?.split(" ")[0] || "" + + return ( + + + + + + + {firstName ? `${firstName}'s` : "Your"} + + + supermemory + + + + + ) +} + +export function SettingsContent({ + activeTab, + onTabChange, + className, + showIdentity = true, + onClose, +}: { + activeTab: SettingsTab + onTabChange: (tab: SettingsTab) => void + className?: string + showIdentity?: boolean + onClose?: () => void +}) { + const { user, org } = useAuth() + const router = useRouter() + const isMobile = useIsMobile() + const localStorageUsername = useLocalStorageUsername() + + const [isResetDialogOpen, setIsResetDialogOpen] = useState(false) + const [resetConfirmation, setResetConfirmation] = useState("") + const resetOrganization = useResetOrganization() + + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) + const [deleteEmailConfirm, setDeleteEmailConfirm] = useState("") + const deleteUserAccount = useDeleteUserAccount() + + const [dangerMenuOpen, setDangerMenuOpen] = useState(false) + + const displayName = + user?.displayUsername || + localStorageUsername || + user?.name || + user?.email?.split("@")[0] || + "" + + const handleLogout = async () => { + await authClient.signOut() + router.push("/login") + } + + const handleIntegrations = () => { + router.push("/?view=integrations") + onClose?.() + } + + const handleDeleteAccount = async () => { + if (deleteEmailConfirm !== user?.email) return + deleteUserAccount.mutate( + { confirmation: deleteEmailConfirm }, + { + onSuccess: () => { + setIsDeleteDialogOpen(false) + setDeleteEmailConfirm("") + router.push("/login") + }, + }, + ) + } + + return ( + <> + + {/* Left rail */} + + + {/* Content */} + + + Something went wrong loading this section.{" "} + window.location.reload()} + > + Reload + + + } + > + {activeTab === "account" && } + {activeTab === "billing" && } + {activeTab === "integrations" && } + {activeTab === "connections" && } + {activeTab === "support" && } + + + + + {/* Reset data dialog */} + {(() => { + const confirmText = org?.name || user?.name || "" + return ( + { + setIsResetDialogOpen(open) + if (!open) setResetConfirmation("") + }} + > + + + + + Reset all data? + + + This permanently removes: + + + All documents and memories + All connections (Google Drive, Notion, etc.) + All custom spaces (default space stays) + Organization settings and filters + + + Your account and billing plan stay intact.{" "} + + This cannot be undone. + + + + + + Type{" "} + + {confirmText || "your name"} + {" "} + to confirm: + + setResetConfirmation(e.target.value)} + placeholder={confirmText || "Your name"} + autoComplete="off" + className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C7991B]/50 transition-colors" + /> + + + + + Cancel + + + + resetOrganization.mutate( + { confirmation: confirmText }, + { + onSuccess: () => { + setIsResetDialogOpen(false) + setResetConfirmation("") + }, + }, + ) + } + className="flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#1A1200] text-[#C7991B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90" + > + {resetOrganization.isPending ? ( + + ) : ( + + )} + {resetOrganization.isPending + ? "Resetting…" + : "Reset organization"} + + + + + + ) + })()} + + {/* Delete account dialog */} + { + setIsDeleteDialogOpen(open) + if (!open) setDeleteEmailConfirm("") + }} + > + + + + + Delete your account? + + + Permanently deletes all your data and cancels any active + subscriptions.{" "} + + This cannot be undone. + + + + + + Type your email{" "} + {user?.email} to + confirm: + + setDeleteEmailConfirm(e.target.value)} + placeholder={user?.email ?? "your@email.com"} + className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C73B1B]/50 transition-colors" + /> + + + + + Cancel + + + + {deleteUserAccount.isPending ? ( + + ) : ( + + )} + {deleteUserAccount.isPending ? "Deleting…" : "Delete account"} + + + + + + > + ) +} diff --git a/apps/web/components/settings/settings-modal.tsx b/apps/web/components/settings/settings-modal.tsx new file mode 100644 index 000000000..ee4a57843 --- /dev/null +++ b/apps/web/components/settings/settings-modal.tsx @@ -0,0 +1,143 @@ +"use client" + +import { + createContext, + useCallback, + useContext, + useMemo, + type ReactNode, +} from "react" +import { useQueryState } from "nuqs" +import { + Dialog, + DialogClose, + DialogContent, + DialogTitle, +} from "@ui/components/dialog" +import { X } from "lucide-react" +import { cn } from "@lib/utils" +import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" +import { analytics } from "@/lib/analytics" +import { + SettingsContent, + TABS, + type SettingsTab, +} from "@/components/settings/settings-content" + +const SETTINGS_PARAM = "settings" + +/** Integrations is a link-out, not a modal tab, so it falls back to account. */ +function parseTab(value: string | null): SettingsTab { + if ( + value && + value !== "integrations" && + TABS.includes(value as SettingsTab) + ) { + return value as SettingsTab + } + return "account" +} + +type SettingsModalContextValue = { + open: boolean + openSettings: (tab?: SettingsTab) => void + close: () => void +} + +const SettingsModalContext = createContext( + null, +) + +export function SettingsModalProvider({ children }: { children: ReactNode }) { + const [param, setParam] = useQueryState(SETTINGS_PARAM) + const open = param !== null + const tab = parseTab(param) + + const openSettings = useCallback( + (next?: SettingsTab) => { + setParam(next ?? "account") + }, + [setParam], + ) + + const close = useCallback(() => setParam(null), [setParam]) + + const handleTabChange = useCallback( + (next: SettingsTab) => { + setParam(next) + analytics.settingsTabChanged({ tab: next }) + }, + [setParam], + ) + + const value = useMemo( + () => ({ open, openSettings, close }), + [open, openSettings, close], + ) + + return ( + + {children} + { + if (!next) setParam(null) + }} + > + + + + Settings + + + + Close + + + + setParam(null)} + className="flex-1 min-h-0 w-full overflow-y-auto md:overflow-hidden px-5 md:px-4 pt-4 pb-6" + /> + + + + ) +} + +export function useSettingsModal() { + const ctx = useContext(SettingsModalContext) + if (!ctx) { + throw new Error( + "useSettingsModal must be used within a SettingsModalProvider", + ) + } + return ctx +} diff --git a/apps/web/components/settings/settings-org-switcher.tsx b/apps/web/components/settings/settings-org-switcher.tsx new file mode 100644 index 000000000..9ebcf9b52 --- /dev/null +++ b/apps/web/components/settings/settings-org-switcher.tsx @@ -0,0 +1,262 @@ +"use client" + +import { useMemo, useState } from "react" +import { useCustomer } from "autumn-js/react" +import { toast } from "sonner" +import { + Building2, + Check, + ChevronsUpDown, + LoaderIcon, + Plus, +} from "lucide-react" +import { cn } from "@lib/utils" +import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" +import { useAuth } from "@lib/auth-context" +import { authClient } from "@lib/auth" +import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover" +import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog" +import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge" +import { useOrgSummaries } from "@/hooks/use-org-summaries" +import { useTokenUsage, type PlanType } from "@/hooks/use-token-usage" + +const SURFACE_SHADOW = + "0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset" + +function generateOrgSlug(name: string): string { + const base = + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") || "org" + return `${base}-${Math.floor(100000 + Math.random() * 900000)}` +} + +export function SettingsOrgSwitcher() { + const { org, organizations, setActiveOrg } = useAuth() + const autumn = useCustomer() + const { currentPlan } = useTokenUsage(autumn) + const { data: orgSummaries } = useOrgSummaries() + + const [open, setOpen] = useState(false) + const [switchingId, setSwitchingId] = useState(null) + const [createOpen, setCreateOpen] = useState(false) + const [createName, setCreateName] = useState("") + const [creating, setCreating] = useState(false) + + const planByOrgId = useMemo(() => { + const map = new Map() + for (const summary of orgSummaries ?? []) { + map.set(summary.orgId, summary.plan) + } + return map + }, [orgSummaries]) + + const activeOrgPlan = org?.id + ? resolveOrgPlan(org.id, true, currentPlan, planByOrgId) + : currentPlan + + const sortedOrgs = useMemo( + () => + [...(organizations ?? [])].sort((a, b) => a.name.localeCompare(b.name)), + [organizations], + ) + + const handleSwitch = async (slug: string, id: string) => { + if (id === org?.id) { + setOpen(false) + return + } + setSwitchingId(id) + try { + await setActiveOrg(slug) + window.location.reload() + } catch (error) { + console.error("Failed to switch organization:", error) + setSwitchingId(null) + toast.error("Failed to switch organization") + } + } + + const handleCreate = async () => { + const name = createName.trim() + if (!name || creating) return + setCreating(true) + try { + const result = await authClient.organization.create({ + name, + slug: generateOrgSlug(name), + metadata: { signupSource: "consumer" }, + }) + if (result.error) { + throw new Error(result.error.message ?? "Failed to create organization") + } + await setActiveOrg(result.data?.slug ?? "") + window.location.reload() + } catch (error) { + setCreating(false) + toast.error( + error instanceof Error + ? error.message + : "Failed to create organization", + ) + } + } + + return ( + <> + + + + + + + + {org?.name ?? "Personal"} + + + + + + + {sortedOrgs.map((organization) => { + const isCurrent = organization.id === org?.id + const isSwitching = switchingId === organization.id + const plan = resolveOrgPlan( + organization.id, + isCurrent, + currentPlan, + planByOrgId, + ) + return ( + handleSwitch(organization.slug, organization.id)} + className={cn( + "w-full flex items-center gap-2.5 rounded-[10px] px-3 py-2 text-left transition-colors", + isCurrent ? "bg-white/5" : "hover:bg-white/5 cursor-pointer", + "disabled:cursor-default", + )} + > + + + {organization.name} + + {isSwitching ? ( + + ) : isCurrent ? ( + + ) : null} + + + ) + })} + + + + { + setOpen(false) + setCreateOpen(true) + }} + className="w-full flex items-center gap-2.5 rounded-[10px] px-3 py-2 text-left text-[#A3A3A3] transition-colors hover:bg-white/5 hover:text-white cursor-pointer" + > + + + Create organization + + + + + + { + setCreateOpen(next) + if (!next) setCreateName("") + }} + > + + + + + Create organization + + + A separate workspace with its own memories, connections, and + members. + + + setCreateName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate() + }} + placeholder="Organization name" + maxLength={80} + className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#4BA0FA]/50 transition-colors" + /> + + setCreateOpen(false)} + className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer" + > + Cancel + + + {creating ? ( + + ) : ( + + )} + {creating ? "Creating…" : "Create"} + + + + + + > + ) +} diff --git a/apps/web/components/user-profile-menu.tsx b/apps/web/components/user-profile-menu.tsx index 632d015b2..77593fb83 100644 --- a/apps/web/components/user-profile-menu.tsx +++ b/apps/web/components/user-profile-menu.tsx @@ -17,6 +17,7 @@ import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" import { useOrgOnboarding } from "@hooks/use-org-onboarding" import { useTokenUsage } from "@/hooks/use-token-usage" +import { useSettingsModal } from "@/components/settings/settings-modal" export function UserProfileMenu({ className, @@ -29,6 +30,7 @@ export function UserProfileMenu({ }) { const { user } = useAuth() const router = useRouter() + const { openSettings } = useSettingsModal() const { resetOrgOnboarded } = useOrgOnboarding() const autumn = useCustomer() const { currentPlan, isLoading: planLoading } = useTokenUsage(autumn) @@ -144,7 +146,7 @@ export function UserProfileMenu({ router.push("/settings")} + onClick={() => openSettings()} className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer" >
{user?.name ?? "—"} @@ -403,28 +416,32 @@ export default function Account() {
{user?.email ?? "—"}
+
+ Member since -
+ {memberSince} -
Invite people into {org?.name ?? "your organization"} and manage @@ -541,17 +615,6 @@ export default function Account() {
+ Only organization owners and admins can invite teammates or + change roles. +
- Only organization owners and admins can invite teammates or - change roles. -
- Pending invitations -
- {invitation.email} -
- Invited as {formatRole(invitation.role)} -
+ Pending invitations +
- - + {invitation.email} +
+ Invited as {formatRole(invitation.role)} +
{children} @@ -72,55 +19,9 @@ function SectionTitle({ children }: { children: React.ReactNode }) { ) } -function SettingsCard({ children }: { children: React.ReactNode }) { - return ( -
- Guide how Nova processes and remembers your content. -
- {enabled ? "Context is enabled" : "Context is disabled"} -
- {enabled - ? "New content will use your guidance when Nova creates memories." - : "Turn this on to tell Nova what matters most when it learns."} -
- {savedPrompt} -
- No organization context configured.{" "} - setIsManaging(true)} - className="text-[#4BA0FA] transition-colors hover:text-[#7BB8FF] cursor-pointer" - > - Set up now - -
- {template.label} -
- {template.description} -
+ Guide how Supermemory processes and remembers your content. +
+ - {confirmDialog === "enable" - ? "Nova will use your guidance when processing new content across this organization." - : "Nova will stop using this guidance for new content, and the saved context will be cleared."} -
+ {firstName ? `${firstName}'s` : "Your"} +
+ supermemory +
+ This permanently removes: +
+ Your account and billing plan stay intact.{" "} + + This cannot be undone. + +
+ Type{" "} + + {confirmText || "your name"} + {" "} + to confirm: +
+ Permanently deletes all your data and cancels any active + subscriptions.{" "} + + This cannot be undone. + +
+ Type your email{" "} + {user?.email} to + confirm: +
+ A separate workspace with its own memories, connections, and + members. +