mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
feat(settings): settings modal overhaul (#1069)
This commit is contained in:
parent
819090ae9d
commit
3a3324cee3
14 changed files with 1404 additions and 1399 deletions
|
|
@ -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 (
|
||||
<>
|
||||
<SettingsModalProvider>
|
||||
<EnsureWorkspace>{children}</EnsureWorkspace>
|
||||
<PWAInstallPrompt />
|
||||
</>
|
||||
</SettingsModalProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { useEffect } from "react"
|
||||
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"
|
||||
import { parseHashToTab } from "@/components/settings/settings-content"
|
||||
|
||||
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: <UserIcon className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
label: "Billing",
|
||||
description: "Plan, usage and payments",
|
||||
icon: <CreditCard className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: "Integrations",
|
||||
description: "Save, sync and search across tools",
|
||||
icon: <Sun className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "connections",
|
||||
label: "Connections & MCP",
|
||||
description: "Drive, Notion, OneDrive, MCP",
|
||||
icon: <Zap className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "support",
|
||||
label: "Support & Help",
|
||||
description: "Get help or share feedback",
|
||||
icon: <HelpCircle className="size-[18px]" />,
|
||||
},
|
||||
]
|
||||
|
||||
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<PlanType, string> = {
|
||||
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 (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-[18px] min-w-[42px] shrink-0 items-center justify-center rounded-[3px] px-1.5 text-[10px] uppercase",
|
||||
ORG_PLAN_BADGE_STYLES[plan],
|
||||
)}
|
||||
>
|
||||
{PLAN_DISPLAY_NAMES[plan]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function resolveOrgPlan(
|
||||
orgId: string,
|
||||
isCurrent: boolean,
|
||||
currentPlan: PlanType,
|
||||
planByOrgId: Map<string, PlanType>,
|
||||
): PlanType {
|
||||
const fromSummary = planByOrgId.get(orgId)
|
||||
if (fromSummary) return fromSummary
|
||||
if (isCurrent) return currentPlan
|
||||
return "free"
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="px-3 pt-3 pb-1.5 text-[10.5px] font-semibold tracking-[0.14em] text-white/30 uppercase">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IdentityCard({ displayName }: { displayName: string }) {
|
||||
const firstName = displayName?.split(" ")[0] || ""
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center h-[140px]">
|
||||
<NovaOrb size={150} className="blur-[3px]!" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10">
|
||||
<Logo className="h-7 shrink-0 text-white" />
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-start justify-center ml-3.5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<p className="text-white text-[14px] font-medium leading-none">
|
||||
{firstName ? `${firstName}'s` : "Your"}
|
||||
</p>
|
||||
<p className="text-white font-bold text-[20px] leading-none mt-1">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, org, organizations, setActiveOrg } = useAuth()
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>("account")
|
||||
const hasInitialized = useRef(false)
|
||||
/**
|
||||
* Legacy redirect: /settings (and /settings#billing etc.) now open the
|
||||
* settings modal via the ?settings=<tab> 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<string | null>(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<string, PlanType>()
|
||||
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 })
|
||||
router.replace(
|
||||
tab === "integrations" ? "/?view=integrations" : `/?settings=${tab}`,
|
||||
)
|
||||
}, [router])
|
||||
|
||||
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 (
|
||||
<div className="h-screen flex flex-col overflow-hidden bg-[#08090C]">
|
||||
<header className="relative z-20 flex justify-between items-center gap-3 px-4 md:px-8 py-3 shrink-0">
|
||||
<nav
|
||||
className={cn(
|
||||
"flex items-center gap-2 sm:gap-3 min-w-0 text-sm",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
aria-label="Breadcrumb"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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",
|
||||
)}
|
||||
>
|
||||
<Logo className="h-7 shrink-0" />
|
||||
<span className="ml-2 font-semibold text-white/90 tracking-tight">
|
||||
supermemory
|
||||
</span>
|
||||
</button>
|
||||
<span className="text-white/30 shrink-0" aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<span className="text-white/55 font-medium shrink-0">Settings</span>
|
||||
</nav>
|
||||
<div className="flex items-center gap-2 sm:gap-3 shrink-0">
|
||||
{!isMobile &&
|
||||
(canSwitchOrg ? (
|
||||
<Popover open={orgSwitcherOpen} onOpenChange={setOrgSwitcherOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-full pl-1.5 pr-2.5 py-1.5 transition-colors",
|
||||
"bg-white/[0.03] border border-white/[0.06]",
|
||||
"cursor-pointer hover:bg-white/[0.06]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
|
||||
<Building2 className="size-[13px]" />
|
||||
</span>
|
||||
<span className="max-w-[160px] text-left text-[13px] font-medium text-white truncate leading-none">
|
||||
{org?.name ?? "Personal"}
|
||||
</span>
|
||||
<OrgPlanBadge plan={activeOrgPlan} />
|
||||
<ChevronsUpDown className="size-3.5 shrink-0 text-white/40" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
className={cn(
|
||||
"w-[260px] max-h-80 overflow-y-auto p-1.5",
|
||||
"bg-[#14161A] border-white/10 rounded-[14px]",
|
||||
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{[...(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 (
|
||||
<button
|
||||
key={organization.id}
|
||||
type="button"
|
||||
disabled={isCurrent || isSwitching}
|
||||
onClick={() =>
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<Building2 className="size-4 shrink-0 text-white/40" />
|
||||
<span className="min-w-0 flex-1 truncate text-[13.5px] text-white">
|
||||
{organization.name}
|
||||
</span>
|
||||
{isSwitching ? (
|
||||
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#4BA0FA]" />
|
||||
) : isCurrent ? (
|
||||
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
|
||||
) : null}
|
||||
<OrgPlanBadge plan={plan} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-full pl-1.5 pr-2.5 py-1.5",
|
||||
"bg-white/[0.03] border border-white/[0.06]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
|
||||
<Building2 className="size-[13px]" />
|
||||
</span>
|
||||
<span className="max-w-[160px] text-left text-[13px] font-medium text-white truncate leading-none">
|
||||
{org?.name ?? "Personal"}
|
||||
</span>
|
||||
<OrgPlanBadge plan={activeOrgPlan} />
|
||||
</div>
|
||||
))}
|
||||
<UserProfileMenu />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 min-h-0 overflow-y-auto md:overflow-hidden">
|
||||
<div className="flex flex-col md:flex-row gap-4 md:gap-6 lg:gap-10 px-4 md:px-8 pt-5 md:pt-7 pb-6 md:h-full md:w-full md:max-w-[1240px] md:mx-auto">
|
||||
{/* Left rail */}
|
||||
<aside className="md:flex md:h-full md:min-h-0 md:w-[280px] md:flex-col shrink-0">
|
||||
{!isMobile && (
|
||||
<div className="mb-4">
|
||||
<IdentityCard displayName={headerDisplayName} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
"flex",
|
||||
isMobile
|
||||
? "flex-row gap-2 overflow-x-auto pb-2 scrollbar-thin"
|
||||
: "min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{!isMobile && <SectionLabel>Organisation</SectionLabel>}
|
||||
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = activeTab === item.id
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.location.hash = item.id
|
||||
setActiveTab(item.id)
|
||||
analytics.settingsTabChanged({ tab: item.id })
|
||||
}}
|
||||
className={cn(
|
||||
"relative rounded-xl transition-colors flex items-center gap-3 shrink-0 group",
|
||||
isMobile
|
||||
? "px-3 py-2 text-sm border border-white/[0.06]"
|
||||
: "text-left px-3 py-2",
|
||||
isActive
|
||||
? "bg-[#14161A] text-white shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
|
||||
: "text-white/60 hover:text-white hover:bg-white/[0.025]",
|
||||
)}
|
||||
>
|
||||
{!isMobile && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute left-0 top-1/2 -translate-y-1/2 w-[3px] rounded-r-full transition-all",
|
||||
isActive
|
||||
? "h-5 bg-[#4BA0FA]"
|
||||
: "h-0 bg-transparent group-hover:h-3 group-hover:bg-white/20",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 transition-colors",
|
||||
isActive ? "text-white" : "text-white/45",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="font-medium text-[14px] whitespace-nowrap">
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{!isMobile && <div className="flex-1" />}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={cn(
|
||||
"group rounded-xl transition-colors flex items-center gap-3 shrink-0 cursor-pointer text-white/60 hover:text-white hover:bg-white/[0.025]",
|
||||
isMobile
|
||||
? "px-3 py-2 text-sm border border-white/[0.06]"
|
||||
: "text-left px-3 py-2",
|
||||
)}
|
||||
>
|
||||
<LogOut className="size-[18px] shrink-0 text-white/45" />
|
||||
<span className="font-medium text-[14px] whitespace-nowrap">
|
||||
Log out
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Popover open={dangerMenuOpen} onOpenChange={setDangerMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group rounded-xl transition-colors flex items-center gap-3 shrink-0 cursor-pointer",
|
||||
isMobile
|
||||
? "px-3 py-2 text-sm border border-[#3A211C]"
|
||||
: "text-left px-3 py-2.5 mt-1",
|
||||
dangerMenuOpen
|
||||
? "bg-[#1A0F0C] text-[#C73B1B]"
|
||||
: "text-[#8A5247] hover:text-[#C73B1B] hover:bg-[#1A0F0C]/60",
|
||||
)}
|
||||
>
|
||||
<ShieldAlert className="size-[18px] shrink-0" />
|
||||
<span className="font-medium text-[14px] whitespace-nowrap">
|
||||
Danger zone
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 ml-auto shrink-0 transition-transform opacity-60",
|
||||
dangerMenuOpen ? "-rotate-90" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
className={cn(
|
||||
"w-[var(--radix-popover-trigger-width)] min-w-[248px] p-1.5 bg-[#14161A] border-white/10 rounded-[14px]",
|
||||
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDangerMenuOpen(false)
|
||||
setIsResetDialogOpen(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#A37A2E] hover:text-[#C7991B] hover:bg-[#1A1200]/60 transition-colors cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="size-[16px] shrink-0" />
|
||||
<span className="font-medium text-[13.5px]">
|
||||
Reset data
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="my-1 h-px bg-white/[0.06]" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDangerMenuOpen(false)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#C73B1B] hover:bg-[#290F0A]/60 transition-colors cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-[16px] shrink-0" />
|
||||
<span className="font-semibold text-[13.5px]">
|
||||
Delete account
|
||||
</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<section className="flex-1 min-w-0 flex flex-col md:overflow-y-auto md:max-w-4xl [scrollbar-gutter:stable] md:pr-2">
|
||||
<ErrorBoundary
|
||||
key={activeTab}
|
||||
fallback={
|
||||
<p className="text-muted-foreground p-4">
|
||||
Something went wrong loading this section.{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="underline cursor-pointer"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{activeTab === "account" && <Account />}
|
||||
{activeTab === "billing" && <Billing />}
|
||||
{activeTab === "integrations" && <Integrations />}
|
||||
{activeTab === "connections" && <ConnectionsMCP />}
|
||||
{activeTab === "support" && <Support />}
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Reset data dialog */}
|
||||
{(() => {
|
||||
const confirmText = org?.name || user?.name || ""
|
||||
return (
|
||||
<Dialog
|
||||
open={isResetDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsResetDialogOpen(open)
|
||||
if (!open) setResetConfirmation("")
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<div
|
||||
className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
|
||||
Reset all data?
|
||||
</h2>
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
This permanently removes:
|
||||
</p>
|
||||
<ul className="text-sm text-[#8B8B8B] list-disc pl-5 space-y-0.5 mt-1">
|
||||
<li>All documents and memories</li>
|
||||
<li>All connections (Google Drive, Notion, etc.)</li>
|
||||
<li>All custom spaces (default space stays)</li>
|
||||
<li>Organization settings and filters</li>
|
||||
</ul>
|
||||
<p className="text-sm text-[#8B8B8B] mt-1">
|
||||
Your account and billing plan stay intact.{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
This cannot be undone.
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Type{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
{confirmText || "your name"}
|
||||
</strong>{" "}
|
||||
to confirm:
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={resetConfirmation}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!confirmText ||
|
||||
resetConfirmation !== confirmText ||
|
||||
resetOrganization.isPending
|
||||
}
|
||||
title={
|
||||
!confirmText || resetConfirmation !== confirmText
|
||||
? `Type "${confirmText || "your name"}" exactly to enable`
|
||||
: undefined
|
||||
}
|
||||
onClick={() =>
|
||||
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 ? (
|
||||
<LoaderIcon className="size-[15px] animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="size-[15px]" />
|
||||
)}
|
||||
{resetOrganization.isPending
|
||||
? "Resetting…"
|
||||
: "Reset organization"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Delete account dialog */}
|
||||
<Dialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDeleteDialogOpen(open)
|
||||
if (!open) setDeleteEmailConfirm("")
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<div className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
|
||||
Delete your account?
|
||||
</h2>
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Permanently deletes all your data and cancels any active
|
||||
subscriptions.{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
This cannot be undone.
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Type your email{" "}
|
||||
<strong className="text-[#FAFAFA]">{user?.email}</strong> to
|
||||
confirm:
|
||||
</p>
|
||||
<input
|
||||
type="email"
|
||||
value={deleteEmailConfirm}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
deleteEmailConfirm !== user?.email ||
|
||||
deleteUserAccount.isPending
|
||||
}
|
||||
title={
|
||||
deleteEmailConfirm !== user?.email
|
||||
? `Type ${user?.email ?? "your email"} exactly to confirm`
|
||||
: undefined
|
||||
}
|
||||
onClick={handleDeleteAccount}
|
||||
className="relative flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#290F0A] text-[#C73B1B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
|
||||
>
|
||||
{deleteUserAccount.isPending ? (
|
||||
<LoaderIcon className="size-[15px] animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-[15px]" />
|
||||
)}
|
||||
{deleteUserAccount.isPending ? "Deleting…" : "Delete account"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<MoreItem
|
||||
icon={Settings}
|
||||
label="Settings"
|
||||
onClick={() => router.push("/settings")}
|
||||
onClick={() => openSettings()}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -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<typeof DocumentsWithMemoriesResponseSchema>
|
||||
|
|
@ -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: <Settings className="size-4 text-[#737373]" />,
|
||||
action: () => close(() => router.push("/settings")),
|
||||
action: () => close(() => openSettings()),
|
||||
},
|
||||
...(onAddMemory
|
||||
? [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Settings className="size-4 text-[#737373]" />
|
||||
|
|
|
|||
|
|
@ -732,15 +732,23 @@ function FeaturedHero({ picks }: { picks: FeaturedPick[] }) {
|
|||
if (!pick) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
// biome-ignore lint/a11y/useSemanticElements: card wraps nested dot buttons, so it can't be a <button>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={pick.onCta}
|
||||
onKeyDown={(e) => {
|
||||
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[] }) {
|
|||
</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA] px-2",
|
||||
"font-semibold text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{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<InviteRole>("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 (
|
||||
<div className="flex flex-col gap-8 w-full">
|
||||
<section id="profile-details" className="flex flex-col gap-4">
|
||||
<SectionTitle>Profile Details</SectionTitle>
|
||||
<div className="flex flex-col gap-5 w-full">
|
||||
<section id="profile-details">
|
||||
<SettingsCard>
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Avatar + Name/Email */}
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<div className="relative size-16 rounded-full bg-linear-to-b from-[#0D121A] to-black overflow-hidden shrink-0">
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Identity */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="relative size-11 rounded-full bg-linear-to-b from-[#0D121A] to-black overflow-hidden shrink-0">
|
||||
<Avatar className="size-full">
|
||||
<AvatarImage
|
||||
src={user?.image ?? ""}
|
||||
alt={user?.name ?? "User"}
|
||||
className="object-cover"
|
||||
/>
|
||||
<AvatarFallback className="bg-transparent text-white text-xl">
|
||||
<AvatarFallback className="bg-transparent text-white text-base">
|
||||
{user?.name?.charAt(0) ?? "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 pt-0.5">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA]",
|
||||
"truncate font-semibold text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{user?.name ?? "—"}
|
||||
|
|
@ -403,28 +416,32 @@ export default function Account() {
|
|||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
"truncate text-[13px] tracking-[-0.13px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{user?.email ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<OrgPlanBadge plan={currentPlan} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<p
|
||||
{/* Fields */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
|
||||
"text-[12px] tracking-[-0.12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Organization
|
||||
</p>
|
||||
</span>
|
||||
{isEditingOrgName ? (
|
||||
<form
|
||||
onSubmit={handleOrgNameSubmit}
|
||||
className="flex min-w-0 max-w-full items-center gap-1.5 sm:max-w-[360px]"
|
||||
className="flex min-w-0 max-w-full items-center gap-1.5 sm:max-w-[320px]"
|
||||
>
|
||||
<input
|
||||
value={orgNameDraft}
|
||||
|
|
@ -433,51 +450,47 @@ export default function Account() {
|
|||
maxLength={80}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"h-9 min-w-0 flex-1 rounded-[9px] border border-white/10 bg-black/30 px-3 text-[14px] font-medium tracking-[-0.14px] text-[#FAFAFA] outline-none transition-colors placeholder:text-[#525252] focus:border-[#4BA0FA]/60",
|
||||
"h-8 min-w-0 flex-1 rounded-[9px] border border-white/10 bg-black/30 px-2.5 text-[13px] font-medium tracking-[-0.13px] text-[#FAFAFA] outline-none transition-colors placeholder:text-[#525252] focus:border-[#4BA0FA]/60",
|
||||
)}
|
||||
placeholder="Organization name"
|
||||
/>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
updateOrgNameMutation.isPending ||
|
||||
!orgNameDraft.trim() ||
|
||||
orgNameDraft.trim() === org?.name
|
||||
}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-8 items-center justify-center gap-1 rounded-full border border-transparent bg-[#0D121A] px-2.5 text-[11px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{updateOrgNameMutation.isPending ? (
|
||||
<LoaderIcon className="size-3 animate-spin" />
|
||||
) : null}
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateOrgNameMutation.isPending}
|
||||
aria-label="Cancel organization name edit"
|
||||
title="Cancel"
|
||||
onClick={() => {
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
updateOrgNameMutation.isPending ||
|
||||
!orgNameDraft.trim() ||
|
||||
orgNameDraft.trim() === org?.name
|
||||
}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"inline-flex h-7 items-center justify-center gap-1 rounded-full border border-transparent bg-[#0D121A] px-2.5 text-[11px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{updateOrgNameMutation.isPending ? (
|
||||
<LoaderIcon className="size-3 animate-spin" />
|
||||
) : null}
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateOrgNameMutation.isPending}
|
||||
aria-label="Cancel organization name edit"
|
||||
title="Cancel"
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex min-w-0 max-w-full items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
"truncate font-medium text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{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]"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
<Pencil className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<p
|
||||
<div className="flex shrink-0 flex-col items-end gap-1 text-right">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
|
||||
"text-[12px] tracking-[-0.12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Member since
|
||||
</p>
|
||||
<p
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
|
||||
"font-medium text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{memberSince}
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3 px-1">
|
||||
<SectionTitle>Overview</SectionTitle>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{[
|
||||
{
|
||||
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 }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex flex-col gap-3 rounded-[14px] bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
|
||||
>
|
||||
<span className="flex size-8 items-center justify-center rounded-[10px] bg-[#0D121A] text-[#737373] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[24px] font-semibold leading-none tracking-[-0.5px] text-[#FAFAFA] tabular-nums",
|
||||
)}
|
||||
>
|
||||
{typeof value === "number" ? value.toLocaleString() : "—"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] tracking-[-0.12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<OrgContext />
|
||||
|
||||
<section id="team-members" className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-2">
|
||||
<section id="team-members" className="flex flex-col gap-4 px-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<SectionTitle>Team members</SectionTitle>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<SectionTitle>Team members</SectionTitle>
|
||||
{(org?.members?.length ?? 0) > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] tabular-nums text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{org?.members?.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] tracking-[-0.13px] text-[#737373] px-2",
|
||||
"text-[13px] tracking-[-0.13px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Invite people into {org?.name ?? "your organization"} and manage
|
||||
|
|
@ -541,17 +615,6 @@ export default function Account() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{(org?.members?.length ?? 0) > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] tracking-[-0.13px] text-[#737373] tabular-nums",
|
||||
)}
|
||||
>
|
||||
{org?.members?.length}{" "}
|
||||
{org?.members?.length === 1 ? "member" : "members"}
|
||||
</span>
|
||||
)}
|
||||
{canManageTeam && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -568,234 +631,209 @@ export default function Account() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SettingsCard>
|
||||
<div className="flex flex-col gap-5">
|
||||
{!canManageTeam && (
|
||||
<div className="flex items-center gap-3 rounded-[12px] border border-white/[0.06] bg-white/[0.02] p-3">
|
||||
<div className="size-9 rounded-full bg-white/[0.04] flex items-center justify-center shrink-0">
|
||||
<Users className="size-4 text-[#737373]" />
|
||||
{showTeamCard && (
|
||||
<div className="rounded-[14px] bg-[#14161A] p-2 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="flex flex-col gap-2">
|
||||
{!canManageTeam && (
|
||||
<div className="flex items-center gap-3 rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2.5">
|
||||
<div className="size-8 rounded-full bg-white/[0.04] flex items-center justify-center shrink-0">
|
||||
<Users className="size-4 text-[#737373]" />
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12.5px] tracking-[-0.12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Only organization owners and admins can invite teammates or
|
||||
change roles.
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] tracking-[-0.13px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Only organization owners and admins can invite teammates or
|
||||
change roles.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{pendingInvitations.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[11px] uppercase tracking-[0.12em] text-[#737373] font-mono",
|
||||
)}
|
||||
>
|
||||
Pending invitations
|
||||
</p>
|
||||
<ul className="flex flex-col rounded-[12px] border border-white/[0.05] overflow-hidden">
|
||||
{pendingInvitations.map((invitation) => (
|
||||
<li
|
||||
key={invitation.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 border-t border-white/[0.04] first:border-t-0 bg-white/[0.015]"
|
||||
>
|
||||
<div className="size-9 rounded-full bg-[#0D121A] flex items-center justify-center shrink-0">
|
||||
<Mail className="size-4 text-[#737373]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate text-[14px] font-medium tracking-[-0.14px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{invitation.email}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] tracking-[-0.12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Invited as {formatRole(invitation.role)}
|
||||
</p>
|
||||
</div>
|
||||
{canManageTeam && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={cancelInvitationMutation.isPending}
|
||||
onClick={() =>
|
||||
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"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{org?.members && org.members.length > 0 ? (
|
||||
<ul className="flex flex-col">
|
||||
{[...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 (
|
||||
{pendingInvitations.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[11px] uppercase tracking-[0.12em] text-[#737373] font-mono",
|
||||
)}
|
||||
>
|
||||
Pending invitations
|
||||
</p>
|
||||
<ul className="flex flex-col rounded-[12px] border border-white/[0.05] overflow-hidden">
|
||||
{pendingInvitations.map((invitation) => (
|
||||
<li
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 py-2.5",
|
||||
idx > 0 && "border-t border-white/[0.04]",
|
||||
)}
|
||||
key={invitation.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 border-t border-white/[0.04] first:border-t-0 bg-white/[0.015]"
|
||||
>
|
||||
<Avatar className="size-9 shrink-0 bg-[#0D121A]">
|
||||
<AvatarImage
|
||||
src={m.user?.image ?? ""}
|
||||
alt={name}
|
||||
className="object-cover"
|
||||
/>
|
||||
<AvatarFallback className="bg-transparent text-white text-[13px] font-medium">
|
||||
{(name.charAt(0) || "U").toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[14px] tracking-[-0.14px] text-[#FAFAFA] truncate",
|
||||
)}
|
||||
<div className="size-9 rounded-full bg-[#0D121A] flex items-center justify-center shrink-0">
|
||||
<Mail className="size-4 text-[#737373]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate text-[14px] font-medium tracking-[-0.14px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{invitation.email}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] tracking-[-0.12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Invited as {formatRole(invitation.role)}
|
||||
</p>
|
||||
</div>
|
||||
{canManageTeam && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={cancelInvitationMutation.isPending}
|
||||
onClick={() =>
|
||||
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}
|
||||
</span>
|
||||
{isYou && (
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{org?.members && org.members.length > 0 && (
|
||||
<ul className="flex flex-col">
|
||||
{[...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 (
|
||||
<li
|
||||
key={m.id}
|
||||
className="flex items-center gap-3 rounded-[10px] px-3 py-2.5 hover:bg-white/[0.02]"
|
||||
>
|
||||
<Avatar className="size-8 shrink-0 bg-[#0D121A]">
|
||||
<AvatarImage
|
||||
src={m.user?.image ?? ""}
|
||||
alt={name}
|
||||
className="object-cover"
|
||||
/>
|
||||
<AvatarFallback className="bg-transparent text-white text-[13px] font-medium">
|
||||
{(name.charAt(0) || "U").toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[10.5px] uppercase tracking-[0.1em] text-[#737373] font-mono",
|
||||
"font-medium text-[14px] tracking-[-0.14px] text-[#FAFAFA] truncate",
|
||||
)}
|
||||
>
|
||||
You
|
||||
{name}
|
||||
</span>
|
||||
{isYou && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[10.5px] uppercase tracking-[0.1em] text-[#737373] font-mono",
|
||||
)}
|
||||
>
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{m.user?.email && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] tracking-[-0.12px] text-[#737373] truncate",
|
||||
)}
|
||||
>
|
||||
{m.user.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{m.user?.email && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] tracking-[-0.12px] text-[#737373] truncate",
|
||||
)}
|
||||
{canEditMember ? (
|
||||
<Select
|
||||
value={memberRole}
|
||||
onValueChange={(value) => {
|
||||
if (value === memberRole) return
|
||||
updateMemberRoleMutation.mutate({
|
||||
memberId: m.id,
|
||||
role: value as InviteRole,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{m.user.email}
|
||||
</span>
|
||||
<SelectTrigger className="h-8 w-[112px] rounded-[8px] border-white/[0.08] bg-[#0D0F14] text-[#FAFAFA]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<RolePill role={m.role} />
|
||||
)}
|
||||
</div>
|
||||
{canEditMember ? (
|
||||
<Select
|
||||
value={memberRole}
|
||||
onValueChange={(value) => {
|
||||
if (value === memberRole) return
|
||||
updateMemberRoleMutation.mutate({
|
||||
memberId: m.id,
|
||||
role: value as InviteRole,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[112px] rounded-[8px] border-white/[0.08] bg-[#0D0F14] text-[#FAFAFA]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<RolePill role={m.role} />
|
||||
)}
|
||||
{canEditMember && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-[8px] text-[#737373] hover:bg-white/[0.05] hover:text-[#FAFAFA]"
|
||||
aria-label={`Team actions for ${name}`}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem
|
||||
className="text-[#C73B1B] focus:text-[#C73B1B]"
|
||||
onSelect={() =>
|
||||
removeMemberMutation.mutate(m.id)
|
||||
}
|
||||
disabled={
|
||||
removeMemberMutation.isPending || !isOwner
|
||||
}
|
||||
>
|
||||
<UserMinus className="size-4" />
|
||||
Remove member
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<div className="size-9 rounded-full bg-white/[0.04] flex items-center justify-center shrink-0">
|
||||
<Users className="size-4 text-[#737373]" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Just you for now
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] tracking-[-0.12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Invite teammates to start collaborating.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{canEditMember && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-[8px] text-[#737373] hover:bg-white/[0.05] hover:text-[#FAFAFA]"
|
||||
aria-label={`Team actions for ${name}`}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem
|
||||
className="text-[#C73B1B] focus:text-[#C73B1B]"
|
||||
onSelect={() =>
|
||||
removeMemberMutation.mutate(m.id)
|
||||
}
|
||||
disabled={
|
||||
removeMemberMutation.isPending || !isOwner
|
||||
}
|
||||
>
|
||||
<UserMinus className="size-4" />
|
||||
Remove member
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Dialog
|
||||
|
|
@ -1148,7 +1186,7 @@ export default function Account() {
|
|||
onClick={() => 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</a>
|
||||
</p>
|
||||
|
||||
<ConnectAIModal open={mcpModalOpen} onOpenChange={setMcpModalOpen}>
|
||||
<PillButton onClick={() => setMcpModalOpen(true)}>
|
||||
<Plus className="size-[10px] text-[#FAFAFA]" />
|
||||
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
|
||||
Connect your AI to Supermemory
|
||||
</span>
|
||||
</PillButton>
|
||||
</ConnectAIModal>
|
||||
<PillButton
|
||||
onClick={() => router.push("/?view=integrations&cat=ai-clients")}
|
||||
>
|
||||
<Plus className="size-[10px] text-[#FAFAFA]" />
|
||||
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
|
||||
Connect your AI to Supermemory
|
||||
</span>
|
||||
</PillButton>
|
||||
</div>
|
||||
</ConnectionsCard>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA] px-2",
|
||||
"font-semibold text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -72,55 +19,9 @@ function SectionTitle({ children }: { children: React.ReactNode }) {
|
|||
)
|
||||
}
|
||||
|
||||
function SettingsCard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative bg-[#14161A] rounded-[14px] p-6 w-full overflow-hidden",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PillButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
variant = "default",
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
variant?: "default" | "ghost" | "primary"
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex h-9 items-center justify-center gap-2 rounded-full px-4 text-[13px] font-semibold transition-[color,opacity] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
variant === "ghost"
|
||||
? "px-3 font-medium text-[#737373] hover:bg-white/[0.04] hover:text-[#A3A3A3]"
|
||||
: "border border-transparent bg-[#0D121A] text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] hover:opacity-80",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<section id="organization-context" className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1 px-2">
|
||||
<SectionTitle>Organization Context</SectionTitle>
|
||||
<p
|
||||
<section id="organization-context" className="flex flex-col gap-3 px-1">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<SectionTitle>Organization Context</SectionTitle>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] tracking-[-0.13px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Guide how Supermemory processes and remembers your content.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!settingsReady || updateSettings.isPending}
|
||||
onClick={() => handleToggle(!enabled)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] tracking-[-0.13px] text-[#737373] px-2",
|
||||
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",
|
||||
)}
|
||||
>
|
||||
Guide how Nova processes and remembers your content.
|
||||
</p>
|
||||
{updateSettings.isPending && (
|
||||
<LoaderIcon className="size-3 animate-spin" />
|
||||
)}
|
||||
{enabled ? "DISABLE" : "ENABLE"}
|
||||
</button>
|
||||
</div>
|
||||
<SettingsCard>
|
||||
<div className={cn(dmSansClassName(), "flex flex-col gap-5")}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[15px] font-medium text-[#FAFAFA]">
|
||||
{enabled ? "Context is enabled" : "Context is disabled"}
|
||||
</p>
|
||||
<p className="text-[13px] leading-snug text-[#737373]">
|
||||
{enabled
|
||||
? "New content will use your guidance when Nova creates memories."
|
||||
: "Turn this on to tell Nova what matters most when it learns."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<PillButton
|
||||
onClick={() => setConfirmDialog(enabled ? "disable" : "enable")}
|
||||
disabled={!settingsReady || updateSettings.isPending}
|
||||
variant={enabled ? "ghost" : "primary"}
|
||||
>
|
||||
{enabled ? "DISABLE" : "ENABLE"}
|
||||
</PillButton>
|
||||
{enabled && (
|
||||
<PillButton
|
||||
onClick={() => setIsManaging(true)}
|
||||
|
||||
{enabled && (
|
||||
<div className={cn(dmSansClassName(), "flex flex-col gap-2")}>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-[#737373] tabular-nums">
|
||||
{prompt.length}/750
|
||||
</span>
|
||||
{dirty && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPrompt(savedPrompt)}
|
||||
disabled={updateSettings.isPending}
|
||||
className={cn(
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<Settings className="size-3.5" />
|
||||
MANAGE
|
||||
</PillButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{enabled && !isManaging && savedPrompt && (
|
||||
<div className="rounded-[12px] border border-white/[0.08] bg-[#0D121A] p-4">
|
||||
<p className="line-clamp-6 whitespace-pre-wrap text-[13px] leading-relaxed text-[#A3A3A3]">
|
||||
{savedPrompt}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enabled && !isManaging && !savedPrompt && (
|
||||
<p className="text-[13px] italic text-[#737373]">
|
||||
No organization context configured.{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsManaging(true)}
|
||||
className="text-[#4BA0FA] transition-colors hover:text-[#7BB8FF] cursor-pointer"
|
||||
>
|
||||
Set up now
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{enabled && isManaging && (
|
||||
<div className="overflow-hidden rounded-[18px] border border-white/[0.08] bg-[#1B1F24]">
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-[13px] font-medium text-[#FAFAFA]">
|
||||
What should Nova focus on?
|
||||
</span>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => 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}
|
||||
/>
|
||||
<span className="self-end text-[11px] text-[#737373]">
|
||||
{prompt.length}/750
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{CONTEXT_TEMPLATES.map((template) => {
|
||||
const isSelected = selectedTemplateId === template.id
|
||||
return (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
onClick={() => 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]",
|
||||
)}
|
||||
>
|
||||
<p className="text-[14px] font-medium text-[#FAFAFA]">
|
||||
{template.label}
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] leading-snug text-[#737373]">
|
||||
{template.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-white/[0.08] bg-[#171B22] px-4 py-3">
|
||||
<PillButton onClick={handleCancel} variant="ghost">
|
||||
CANCEL
|
||||
</PillButton>
|
||||
<PillButton
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || updateSettings.isPending}
|
||||
variant="primary"
|
||||
disabled={updateSettings.isPending}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
<LoaderIcon className="size-3 animate-spin" />
|
||||
)}
|
||||
SAVE
|
||||
</PillButton>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<Dialog
|
||||
open={!!confirmDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmDialog(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
style={{
|
||||
boxShadow: SURFACE_SHADOW,
|
||||
}}
|
||||
className={cn(
|
||||
"sm:max-w-[440px] border border-white/[0.12] bg-[#1B1F24] p-0 gap-0 rounded-[22px] overflow-hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[18px] font-semibold tracking-[-0.18px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{confirmDialog === "enable"
|
||||
? "Enable Organization Context?"
|
||||
: "Disable Organization Context?"}
|
||||
</DialogTitle>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] tracking-[-0.13px] leading-relaxed text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{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."}
|
||||
</p>
|
||||
</div>
|
||||
<DialogPrimitive.Close className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#737373] shadow-inside-out transition-opacity hover:opacity-80 focus:outline-hidden cursor-pointer">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<PillButton
|
||||
onClick={() => setConfirmDialog(null)}
|
||||
variant="ghost"
|
||||
>
|
||||
CANCEL
|
||||
</PillButton>
|
||||
<PillButton
|
||||
onClick={handleConfirmToggle}
|
||||
disabled={updateSettings.isPending}
|
||||
variant="primary"
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
)}
|
||||
{confirmDialog === "enable" ? "ENABLE" : "DISABLE"}
|
||||
</PillButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
541
apps/web/components/settings/settings-content.tsx
Normal file
541
apps/web/components/settings/settings-content.tsx
Normal file
|
|
@ -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: <UserIcon className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
label: "Billing",
|
||||
description: "Plan, usage and payments",
|
||||
icon: <CreditCard className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: "Integrations",
|
||||
description: "Save, sync and search across tools",
|
||||
icon: <Sun className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "connections",
|
||||
label: "Connections & MCP",
|
||||
description: "Drive, Notion, OneDrive, MCP",
|
||||
icon: <Zap className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "support",
|
||||
label: "Support & Help",
|
||||
description: "Get help or share feedback",
|
||||
icon: <HelpCircle className="size-[18px]" />,
|
||||
},
|
||||
]
|
||||
|
||||
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 (
|
||||
<div className="relative flex items-center justify-center h-[140px]">
|
||||
<NovaOrb size={150} className="blur-[3px]!" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10">
|
||||
<Logo className="h-7 shrink-0 text-white" />
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-start justify-center ml-3.5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<p className="text-white text-[14px] font-medium leading-none">
|
||||
{firstName ? `${firstName}'s` : "Your"}
|
||||
</p>
|
||||
<p className="text-white font-bold text-[20px] leading-none mt-1">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
className={cn("flex flex-col md:flex-row gap-4 md:gap-5", className)}
|
||||
>
|
||||
{/* Left rail */}
|
||||
<aside className="md:flex md:h-full md:min-h-0 md:w-[248px] md:flex-col shrink-0">
|
||||
{showIdentity && !isMobile && (
|
||||
<div className="mb-4">
|
||||
<IdentityCard displayName={displayName} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3">
|
||||
<SettingsOrgSwitcher />
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
"flex",
|
||||
isMobile
|
||||
? "flex-row gap-2 overflow-x-auto pb-2 scrollbar-thin"
|
||||
: "min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isExternal = item.id === "integrations"
|
||||
const isActive = !isExternal && activeTab === item.id
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
isExternal ? handleIntegrations() : onTabChange(item.id)
|
||||
}
|
||||
className={cn(
|
||||
"relative rounded-xl transition-colors flex items-center gap-3 shrink-0 group",
|
||||
isMobile
|
||||
? "px-3 py-2 text-sm border border-white/[0.06]"
|
||||
: "text-left px-3 py-2",
|
||||
isActive
|
||||
? "bg-[#14161A] text-white shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
|
||||
: "text-white/60 hover:text-white hover:bg-white/[0.025]",
|
||||
)}
|
||||
>
|
||||
{!isMobile && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute left-0 top-1/2 -translate-y-1/2 w-[3px] rounded-r-full transition-all",
|
||||
isActive
|
||||
? "h-5 bg-[#4BA0FA]"
|
||||
: "h-0 bg-transparent group-hover:h-3 group-hover:bg-white/20",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 transition-colors",
|
||||
isActive ? "text-white" : "text-white/45",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="font-medium text-[14px] whitespace-nowrap">
|
||||
{item.label}
|
||||
</span>
|
||||
{isExternal && (
|
||||
<ArrowUpRight className="ml-auto size-4 shrink-0 text-white/30 transition-colors group-hover:text-white/60" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{!isMobile && <div className="flex-1" />}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={cn(
|
||||
"group rounded-xl transition-colors flex items-center gap-3 shrink-0 cursor-pointer text-white/60 hover:text-white hover:bg-white/[0.025]",
|
||||
isMobile
|
||||
? "px-3 py-2 text-sm border border-white/[0.06]"
|
||||
: "text-left px-3 py-2",
|
||||
)}
|
||||
>
|
||||
<LogOut className="size-[18px] shrink-0 text-white/45" />
|
||||
<span className="font-medium text-[14px] whitespace-nowrap">
|
||||
Log out
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Popover open={dangerMenuOpen} onOpenChange={setDangerMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group rounded-xl transition-colors flex items-center gap-3 shrink-0 cursor-pointer",
|
||||
isMobile
|
||||
? "px-3 py-2 text-sm border border-[#3A211C]"
|
||||
: "text-left px-3 py-2.5 mt-1",
|
||||
dangerMenuOpen
|
||||
? "bg-[#1A0F0C] text-[#C73B1B]"
|
||||
: "text-[#8A5247] hover:text-[#C73B1B] hover:bg-[#1A0F0C]/60",
|
||||
)}
|
||||
>
|
||||
<ShieldAlert className="size-[18px] shrink-0" />
|
||||
<span className="font-medium text-[14px] whitespace-nowrap">
|
||||
Danger zone
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 ml-auto shrink-0 transition-transform opacity-60",
|
||||
dangerMenuOpen ? "-rotate-90" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
className={cn(
|
||||
"w-[var(--radix-popover-trigger-width)] min-w-[248px] p-1.5 bg-[#14161A] border-white/10 rounded-[14px]",
|
||||
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDangerMenuOpen(false)
|
||||
setIsResetDialogOpen(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#A37A2E] hover:text-[#C7991B] hover:bg-[#1A1200]/60 transition-colors cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="size-[16px] shrink-0" />
|
||||
<span className="font-medium text-[13.5px]">Reset data</span>
|
||||
</button>
|
||||
|
||||
<div className="my-1 h-px bg-white/[0.06]" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDangerMenuOpen(false)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}}
|
||||
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#C73B1B] hover:bg-[#290F0A]/60 transition-colors cursor-pointer"
|
||||
>
|
||||
<Trash2 className="size-[16px] shrink-0" />
|
||||
<span className="font-semibold text-[13.5px]">
|
||||
Delete account
|
||||
</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<section className="flex-1 min-w-0 flex flex-col md:overflow-y-auto md:max-w-4xl [scrollbar-gutter:stable] md:pr-2">
|
||||
<ErrorBoundary
|
||||
key={activeTab}
|
||||
fallback={
|
||||
<p className="text-muted-foreground p-4">
|
||||
Something went wrong loading this section.{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="underline cursor-pointer"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{activeTab === "account" && <Account />}
|
||||
{activeTab === "billing" && <Billing />}
|
||||
{activeTab === "integrations" && <Integrations />}
|
||||
{activeTab === "connections" && <ConnectionsMCP />}
|
||||
{activeTab === "support" && <Support />}
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Reset data dialog */}
|
||||
{(() => {
|
||||
const confirmText = org?.name || user?.name || ""
|
||||
return (
|
||||
<Dialog
|
||||
open={isResetDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsResetDialogOpen(open)
|
||||
if (!open) setResetConfirmation("")
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<div
|
||||
className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
|
||||
Reset all data?
|
||||
</h2>
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
This permanently removes:
|
||||
</p>
|
||||
<ul className="text-sm text-[#8B8B8B] list-disc pl-5 space-y-0.5 mt-1">
|
||||
<li>All documents and memories</li>
|
||||
<li>All connections (Google Drive, Notion, etc.)</li>
|
||||
<li>All custom spaces (default space stays)</li>
|
||||
<li>Organization settings and filters</li>
|
||||
</ul>
|
||||
<p className="text-sm text-[#8B8B8B] mt-1">
|
||||
Your account and billing plan stay intact.{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
This cannot be undone.
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Type{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
{confirmText || "your name"}
|
||||
</strong>{" "}
|
||||
to confirm:
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={resetConfirmation}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!confirmText ||
|
||||
resetConfirmation !== confirmText ||
|
||||
resetOrganization.isPending
|
||||
}
|
||||
title={
|
||||
!confirmText || resetConfirmation !== confirmText
|
||||
? `Type "${confirmText || "your name"}" exactly to enable`
|
||||
: undefined
|
||||
}
|
||||
onClick={() =>
|
||||
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 ? (
|
||||
<LoaderIcon className="size-[15px] animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="size-[15px]" />
|
||||
)}
|
||||
{resetOrganization.isPending
|
||||
? "Resetting…"
|
||||
: "Reset organization"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Delete account dialog */}
|
||||
<Dialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDeleteDialogOpen(open)
|
||||
if (!open) setDeleteEmailConfirm("")
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<div className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
|
||||
Delete your account?
|
||||
</h2>
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Permanently deletes all your data and cancels any active
|
||||
subscriptions.{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
This cannot be undone.
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Type your email{" "}
|
||||
<strong className="text-[#FAFAFA]">{user?.email}</strong> to
|
||||
confirm:
|
||||
</p>
|
||||
<input
|
||||
type="email"
|
||||
value={deleteEmailConfirm}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
deleteEmailConfirm !== user?.email ||
|
||||
deleteUserAccount.isPending
|
||||
}
|
||||
title={
|
||||
deleteEmailConfirm !== user?.email
|
||||
? `Type ${user?.email ?? "your email"} exactly to confirm`
|
||||
: undefined
|
||||
}
|
||||
onClick={handleDeleteAccount}
|
||||
className="relative flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#290F0A] text-[#C73B1B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
|
||||
>
|
||||
{deleteUserAccount.isPending ? (
|
||||
<LoaderIcon className="size-[15px] animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-[15px]" />
|
||||
)}
|
||||
{deleteUserAccount.isPending ? "Deleting…" : "Delete account"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
143
apps/web/components/settings/settings-modal.tsx
Normal file
143
apps/web/components/settings/settings-modal.tsx
Normal file
|
|
@ -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<SettingsModalContextValue | null>(
|
||||
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 (
|
||||
<SettingsModalContext.Provider value={value}>
|
||||
{children}
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setParam(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "calc(100vw - 1rem)",
|
||||
maxWidth: "1160px",
|
||||
height: "min(840px, 90vh)",
|
||||
boxShadow:
|
||||
"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",
|
||||
}}
|
||||
className={cn(
|
||||
"flex-col min-w-0 border-none bg-[#1B1F24] p-0 gap-0 rounded-[22px] overflow-hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 px-5 md:px-7 pt-5 shrink-0">
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
"font-semibold text-[18px] text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Settings
|
||||
</DialogTitle>
|
||||
<DialogClose
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-full border border-[rgba(115,115,115,0.2)] bg-[#0D121A] text-[#737373] transition-colors hover:text-white cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
style={{
|
||||
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
||||
}}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
|
||||
<SettingsContent
|
||||
activeTab={tab}
|
||||
onTabChange={handleTabChange}
|
||||
showIdentity={false}
|
||||
onClose={() => 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"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SettingsModalContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSettingsModal() {
|
||||
const ctx = useContext(SettingsModalContext)
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useSettingsModal must be used within a SettingsModalProvider",
|
||||
)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
262
apps/web/components/settings/settings-org-switcher.tsx
Normal file
262
apps/web/components/settings/settings-org-switcher.tsx
Normal file
|
|
@ -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<string | null>(null)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createName, setCreateName] = useState("")
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const planByOrgId = useMemo(() => {
|
||||
const map = new Map<string, PlanType>()
|
||||
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 (
|
||||
<>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"flex w-full items-center gap-2 rounded-[12px] border border-white/[0.06] bg-white/[0.03] px-2.5 py-2 transition-colors cursor-pointer hover:bg-white/[0.06]",
|
||||
)}
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
|
||||
<Building2 className="size-[13px]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-left text-[13px] font-medium text-white">
|
||||
{org?.name ?? "Personal"}
|
||||
</span>
|
||||
<OrgPlanBadge plan={activeOrgPlan} />
|
||||
<ChevronsUpDown className="size-3.5 shrink-0 text-white/40" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
style={{
|
||||
maxHeight:
|
||||
"min(360px, var(--radix-popover-content-available-height))",
|
||||
}}
|
||||
className={cn(
|
||||
"w-[var(--radix-popover-trigger-width)] min-w-[220px] overflow-y-auto p-1.5",
|
||||
"bg-[#14161A] border-white/10 rounded-[14px]",
|
||||
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{sortedOrgs.map((organization) => {
|
||||
const isCurrent = organization.id === org?.id
|
||||
const isSwitching = switchingId === organization.id
|
||||
const plan = resolveOrgPlan(
|
||||
organization.id,
|
||||
isCurrent,
|
||||
currentPlan,
|
||||
planByOrgId,
|
||||
)
|
||||
return (
|
||||
<button
|
||||
key={organization.id}
|
||||
type="button"
|
||||
disabled={isCurrent || isSwitching}
|
||||
onClick={() => 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",
|
||||
)}
|
||||
>
|
||||
<Building2 className="size-4 shrink-0 text-white/40" />
|
||||
<span className="min-w-0 flex-1 truncate text-[13.5px] text-white">
|
||||
{organization.name}
|
||||
</span>
|
||||
{isSwitching ? (
|
||||
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#4BA0FA]" />
|
||||
) : isCurrent ? (
|
||||
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
|
||||
) : null}
|
||||
<OrgPlanBadge plan={plan} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="my-1 h-px bg-white/[0.06]" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
<Plus className="size-4 shrink-0" />
|
||||
<span className="text-[13.5px] font-medium">
|
||||
Create organization
|
||||
</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onOpenChange={(next) => {
|
||||
setCreateOpen(next)
|
||||
if (!next) setCreateName("")
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
style={{ boxShadow: SURFACE_SHADOW }}
|
||||
className={cn(
|
||||
"sm:max-w-[420px] border border-white/[0.12] bg-[#1B1F24] p-5 gap-0 rounded-[22px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[18px] font-semibold tracking-[-0.18px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Create organization
|
||||
</DialogTitle>
|
||||
<p className="text-[13px] tracking-[-0.13px] leading-relaxed text-[#737373]">
|
||||
A separate workspace with its own memories, connections, and
|
||||
members.
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
value={createName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!createName.trim() || creating}
|
||||
onClick={handleCreate}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#0D121A] text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
|
||||
>
|
||||
{creating ? (
|
||||
<LoaderIcon className="size-[15px] animate-spin" />
|
||||
) : (
|
||||
<Plus className="size-[15px]" />
|
||||
)}
|
||||
{creating ? "Creating…" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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({
|
|||
</div>
|
||||
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
|
||||
<DropdownMenuItem
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Settings className="size-4 text-[#737373]" />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue