diff --git a/apps/docs/connectors/s3.mdx b/apps/docs/connectors/s3.mdx index fde1bed7..438c5d44 100644 --- a/apps/docs/connectors/s3.mdx +++ b/apps/docs/connectors/s3.mdx @@ -4,7 +4,7 @@ description: "Connect Amazon S3 or S3-compatible storage to sync files into your icon: "aws" --- -Connect Amazon S3 buckets or S3-compatible storage services (MinIO, DigitalOcean Spaces, Cloudflare R2) to sync files into your Supermemory knowledge base. +Connect Amazon S3 buckets or S3-compatible storage services (MinIO, DigitalOcean Spaces, Cloudflare R2, Tigris) to sync files into your Supermemory knowledge base. The S3 connector requires a **Scale Plan** or higher. You can also create S3 connections directly from the [Supermemory Console](https://console.supermemory.ai). @@ -78,7 +78,7 @@ For S3, provider-specific connection fields are passed inside the top-level `met | `accessKeyId` | `metadata.accessKeyId` | Yes | AWS access key ID or S3-compatible service key | | `secretAccessKey` | `metadata.secretAccessKey` | Yes | AWS secret access key | | `bucket` | `metadata.bucket` | Yes | S3 bucket name | -| `region` | `metadata.region` | Yes | AWS region (e.g., `us-east-1`). Use `auto` for Cloudflare R2. | +| `region` | `metadata.region` | Yes | AWS region (e.g., `us-east-1`). Use `auto` for S3-compatible providers that don't expose AWS-style regions (MinIO, R2, Tigris). | | `endpoint` | `metadata.endpoint` | No | Custom endpoint for S3-compatible services | | `prefix` | `metadata.prefix` | No | Key prefix filter (e.g., `documents/`) | | `containerTagRegex` | `metadata.containerTagRegex` | No | Regex to extract container tags from file paths | @@ -91,7 +91,7 @@ In the Python SDK, use `container_tags` for the top-level option, but keep S3 me ## S3-Compatible Services -Use `metadata.endpoint` to connect to S3-compatible storage: +Use `metadata.endpoint` to connect to S3-compatible storage. These services don't use AWS-style regions, so set `metadata.region` to `auto` — the value is still required for request signing but the service ignores it. ```typescript // MinIO @@ -100,7 +100,7 @@ const connection = await client.connections.create('s3', { accessKeyId: 'minio-key', secretAccessKey: 'minio-secret', bucket: 'my-bucket', - region: 'us-east-1', + region: 'auto', endpoint: 'https://minio.example.com' }, containerTags: ['minio-sync'] @@ -113,6 +113,7 @@ Common S3-compatible endpoint values: |---------|----------------------|-------------------| | DigitalOcean Spaces | `https://nyc3.digitaloceanspaces.com` | `nyc3` | | Cloudflare R2 | `https://.r2.cloudflarestorage.com` | `auto` | +| Tigris | `https://t3.storage.dev` | `auto` | Cloudflare R2 example: diff --git a/apps/docs/install.md b/apps/docs/install.md index 78830cc5..2968ea08 100644 --- a/apps/docs/install.md +++ b/apps/docs/install.md @@ -46,7 +46,10 @@ export SUPERMEMORY_API_KEY="sm_..." // PATCH https://api.supermemory.ai/v3/settings fetch('https://api.supermemory.ai/v3/settings', { method: 'PATCH', - headers: { 'x-supermemory-api-key': process.env.SUPERMEMORY_API_KEY }, + headers: { + 'Authorization': `Bearer ${process.env.SUPERMEMORY_API_KEY}`, + 'Content-Type': 'application/json', + }, body: JSON.stringify({ shouldLLMFilter: true, filterPrompt: `This is a [your app description]. containerTag is [userId/orgId]. We store [what data].` @@ -210,17 +213,20 @@ client.add(content=f"user: {user_message}\\nassistant: {response}", container_ta ```bash # Add memory curl -X POST https://api.supermemory.ai/v3/documents \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"content": "conversation", "containerTag": "userId"}' # Get profile curl -X POST https://api.supermemory.ai/v4/profile \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"containerTag": "userId", "q": "search query"}' # Search curl -X POST https://api.supermemory.ai/v4/search \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"q": "query", "containerTag": "userId", "searchMode": "hybrid"}' ``` @@ -234,7 +240,10 @@ formData.append('containerTag', userId) await fetch('https://api.supermemory.ai/v3/documents/file', { method: 'POST', - headers: { 'x-supermemory-api-key': process.env.SUPERMEMORY_API_KEY }, + headers: { + 'Authorization': `Bearer ${process.env.SUPERMEMORY_API_KEY}`, + 'Content-Type': 'application/json', + }, body: formData }) @@ -282,17 +291,20 @@ await client.search({ ```bash # 1. Configure settings curl -X PATCH https://api.supermemory.ai/v3/settings \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"shouldLLMFilter": true, "filterPrompt": "..."}' # 2. Add test memory curl -X POST https://api.supermemory.ai/v3/documents \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"content": "Test", "containerTag": "test_user"}' # 3. Get profile curl -X POST https://api.supermemory.ai/v4/profile \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"containerTag": "test_user"}' ``` diff --git a/apps/docs/vibe-coding.mdx b/apps/docs/vibe-coding.mdx index def08fdf..0899692c 100644 --- a/apps/docs/vibe-coding.mdx +++ b/apps/docs/vibe-coding.mdx @@ -52,6 +52,39 @@ You are integrating Supermemory into my application. Supermemory provides user m Note: You can always reference the documentation by using the **SearchSupermemoryDocs MCP** or running a web search tool for content on **supermemory.ai/docs**. +CANONICAL API SURFACE (use these, nothing else): + +- Auth header: `Authorization: Bearer $SUPERMEMORY_API_KEY` — the only supported auth header +- Write content: POST https://api.supermemory.ai/v3/documents +- Search: POST https://api.supermemory.ai/v4/search +- Profile + search: POST https://api.supermemory.ai/v4/profile +- Settings: PATCH https://api.supermemory.ai/v3/settings +- Scoping: `containerTag` (singular string) in the JSON body — never in a header +- SDK: `client.documents.add()`, `client.search.memories()`, `client.profile()` + +DO NOT USE — these are deprecated, undocumented, or fabricated by previous AI codegen: + +- Endpoints: /v1/anything, /v3/memories, /v3/search (use /v3/documents and /v4/search) +- Headers: x-supermemory-api-key, x-api-key, x-sm-user-id, x-sm-project, + x-project-id, X-Workspace-Id (always use Authorization: Bearer) +- Body keys: containerTags (plural array), userId, spaces, schema, container, + tags (top-level), filter (singular) (use containerTag + filters) +- SDK calls: client.search.execute, client.documents.add (use client.add), + client.documents.deleteBulk, client.documents.batch_add, + client.memories.updateMemory (the real method is client.memories.update) +- Kwargs: chunk_threshold (use `threshold`), sort, order, include_content, + include_full_docs, timeout (as an SDK kwarg) + +NOTE on memory mutation: `client.memories.update`, `client.memories.delete`, and +`client.memories.forget` ARE real and supported — but most apps don't need them. +Memories are auto-extracted from documents. Only reach for these if you're exposing +a "manage my memories" UI to end users or agents. +- Mixing: `rerank` and `rewriteQuery` are valid on /v4/search ONLY — never on /v3/search + +SCOPING IS LOAD-BEARING. Every write and every search MUST include `containerTag`. +If you omit it, every user's data collapses into the API key's default bucket — this +is the single most common bug in AI-generated Supermemory integrations. + STEP 1: ASK ME THESE QUESTIONS 1. What are you building? @@ -91,29 +124,34 @@ export SUPERMEMORY_API_KEY="sm_..." STEP 3: CONFIGURE SETTINGS (DO THIS FIRST) -typescript +```typescript // PATCH https://api.supermemory.ai/v3/settings fetch('https://api.supermemory.ai/v3/settings', { method: 'PATCH', - headers: { 'x-supermemory-api-key': process.env.SUPERMEMORY_API_KEY }, + headers: { + 'Authorization': `Bearer ${process.env.SUPERMEMORY_API_KEY}`, + 'Content-Type': 'application/json', + }, body: JSON.stringify({ shouldLLMFilter: true, filterPrompt: `This is a [your app description]. containerTag is [userId/orgId]. We store [what data].` }) }) +``` STEP 4: CONTAINER TAG STRATEGY Based on their data model answer: USER-ONLY APP: -typescript - +```typescript +containerTag: userId // Each user's memories are isolated +``` ORG-ONLY APP: -typescript +```typescript containerTag: orgId // Org members share memories - +``` BOTH (ask which): - Option A: `containerTag: \`\${userId}-\${orgId}\`` @@ -126,7 +164,7 @@ Based on their integration choice: --- VERCEL AI SDK --- -typescript +```typescript import { streamText } from 'ai' import { anthropic } from '@ai-sdk/anthropic' import { supermemoryTools } from '@supermemory/tools/ai-sdk' @@ -136,7 +174,7 @@ const result = await streamText({ model: anthropic('claude-3-5-sonnet-20241022'), prompt: userMessage, tools: supermemoryTools(process.env.SUPERMEMORY_API_KEY, { - containerTags: [userId] + containerTag: userId // singular string — never an array }) }) // Agent gets searchMemories, addMemory, fetchMemory tools @@ -153,11 +191,12 @@ const result = await generateText({ messages: [{ role: 'user', content: userMessage }] }) // Profile is automatically injected into context +``` --- DIRECT SDK (WITH PROFILES) --- -typescript +```typescript import Supermemory from 'supermemory' const client = new Supermemory() @@ -187,7 +226,7 @@ await client.add({ content: `user: ${userMessage}\nassistant: ${response}`, containerTag: userId }) - +``` --- DIRECT SDK (NO PROFILES) --- @@ -218,9 +257,11 @@ await client.add({ content: `user: ${userMessage}\nassistant: ${response}`, containerTag: userId }) +``` --- PYTHON VERSION --- -python + +```python from supermemory import Supermemory client = Supermemory() @@ -238,28 +279,33 @@ Dynamic: {chr(10).join(profile_data.profile.dynamic)} # Store conversation client.add(content=f"user: {user_message}\\nassistant: {response}", container_tag=user_id) +``` --- DIRECT API --- -bash +```bash # Add memory curl -X POST https://api.supermemory.ai/v3/documents \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"content": "conversation", "containerTag": "userId"}' # Get profile curl -X POST https://api.supermemory.ai/v4/profile \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"containerTag": "userId", "q": "search query"}' # Search curl -X POST https://api.supermemory.ai/v4/search \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"q": "query", "containerTag": "userId", "searchMode": "hybrid"}' +``` STEP 6: FILE UPLOADS (if they need it) -typescript +```typescript // Files are automatically extracted (PDFs, images with OCR, videos with transcription) const formData = new FormData() formData.append('file', fileBlob) @@ -267,28 +313,29 @@ formData.append('containerTag', userId) await fetch('https://api.supermemory.ai/v3/documents/file', { method: 'POST', - headers: { 'x-supermemory-api-key': process.env.SUPERMEMORY_API_KEY }, + headers: { 'Authorization': `Bearer ${process.env.SUPERMEMORY_API_KEY}` }, body: formData }) // Processing is async - check status before assuming searchable // GET /v3/documents/{documentId} - +``` STEP 7: SEARCH MODES -typescript +```typescript // HYBRID (recommended) - searches memories + document chunks searchMode: 'hybrid' // MEMORIES ONLY - just extracted memories, no original text searchMode: 'memories' - +``` STEP 8: METADATA FILTERS (if they need secondary filtering) -typescript -await client.search({ +```typescript +// Always against /v4/search — rerank/rewriteQuery/filters are v4-only +await client.search.memories({ q: query, containerTag: userId, filters: { @@ -298,6 +345,7 @@ await client.search({ ] } }) +``` KEY POINTS: @@ -311,21 +359,25 @@ KEY POINTS: TESTING: -bash +```bash # 1. Configure settings curl -X PATCH https://api.supermemory.ai/v3/settings \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"shouldLLMFilter": true, "filterPrompt": "..."}' # 2. Add test memory curl -X POST https://api.supermemory.ai/v3/documents \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"content": "Test", "containerTag": "test_user"}' # 3. Get profile curl -X POST https://api.supermemory.ai/v4/profile \ - -H "x-supermemory-api-key: $SUPERMEMORY_API_KEY" \ + -H "Authorization: Bearer $SUPERMEMORY_API_KEY" \ + -H "Content-Type: application/json" \ -d '{"containerTag": "test_user"}' +``` NOW: diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index b2a27e07..331f950c 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -1,14 +1,12 @@ "use client" import { EnsureWorkspace } from "@/components/ensure-workspace" -import { NextAppResearchCta } from "@/components/next-app-research-cta" import { PWAInstallPrompt } from "@/components/pwa-install-prompt" export default function AppLayout({ children }: { children: React.ReactNode }) { return ( <> {children} - ) diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index ad35867c..2c5eaf07 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -555,6 +555,7 @@ export default function OnboardingPage() { const fileRef = useRef(null) const pollingRef = useRef | null>(null) const skippingRef = useRef(false) + const [isSkipping, setIsSkipping] = useState(false) const [spotlightCategory, setSpotlightCategory] = useState("productivity") @@ -617,6 +618,7 @@ export default function OnboardingPage() { const handleSkip = useCallback(async () => { if (skippingRef.current) return skippingRef.current = true + setIsSkipping(true) try { await ensureOrg() const pendingPath = consumePendingConnectUrl() @@ -624,6 +626,7 @@ export default function OnboardingPage() { } catch (err) { console.error(err) skippingRef.current = false + setIsSkipping(false) } }, [ensureOrg, router]) @@ -798,9 +801,22 @@ export default function OnboardingPage() { @@ -870,34 +886,36 @@ export default function OnboardingPage() { /> - {isCheckingAccount && ( + {hasDetectedAccount && detected !== "resume" && ( - + {isCheckingAccount ? ( + + ) : canSubmit ? ( + + handleSubmit(detected as "x" | "linkedin") + } + className="rounded-xl size-8 flex items-center justify-center border-[0.5px] border-[#161F2C] hover:scale-[0.95] active:scale-[0.95] transition-transform cursor-pointer" + style={{ + background: + "linear-gradient(180deg, #0D121A -26.14%, #000 100%)", + }} + > + + + ) : null} )} - - {canSubmit && ( - handleSubmit(detected as "x" | "linkedin")} - className="absolute right-1 rounded-xl size-8 flex items-center justify-center border-[0.5px] border-[#161F2C] hover:scale-[0.95] active:scale-[0.95] transition-transform cursor-pointer" - style={{ - background: - "linear-gradient(180deg, #0D121A -26.14%, #000 100%)", - }} - > - - - )} @@ -929,9 +947,6 @@ export default function OnboardingPage() { {currentAccountLookup?.status === "error" && ( )} - {isCheckingAccount && ( - - )} {currentAccountLookup?.message ?? SOURCE_LABEL[detected as "x" | "linkedin"]} diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index 9d8e2a64..c177ec1e 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -11,6 +11,7 @@ import { import { AnimatePresence, motion } from "motion/react" import { useQueryState } from "nuqs" import { Header, PublicHeader } from "@/components/header" +import { MobileBottomNav } from "@/components/bottom-nav" import { ChatSidebar, HomeChatComposer } from "@/components/chat" import { DashboardView } from "@/components/dashboard-view" import { MemoriesGrid } from "@/components/memories-grid" @@ -60,6 +61,7 @@ import { type IntegrationParamValue, } from "@/lib/search-params" import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label" +import { getToolDocumentSpace } from "@/lib/plugin-space" type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] @@ -106,7 +108,7 @@ export default function NewPage() { const isMobile = useIsMobile() const { user, session } = useAuth() - const { selectedProject, selectedProjects } = useProject() + const { selectedProject, selectedProjects, setSelectedProject } = useProject() const selectedProjectTag = selectedProjects[0] const { allProjects } = useContainerTags() const dashboardSpaceLabel = useMemo( @@ -408,6 +410,18 @@ export default function NewPage() { [setDocId], ) + const handleOpenToolDocument = useCallback( + (document: DocumentWithMemories, pluginClientId: string) => { + const documentSpace = getToolDocumentSpace(document, pluginClientId) + if (documentSpace) { + setSelectedProject(documentSpace) + } + handleOpenDocument(document) + void setViewMode("list") + }, + [handleOpenDocument, setSelectedProject, setViewMode], + ) + // Separate from handleOpenDocument because the graph view only has a document ID, // not the full document object. The modal will fetch the document via the docId // query param, so there may be a brief loading state (unlike handleOpenDocument @@ -514,9 +528,13 @@ export default function NewPage() { const handleOpenIntegrations = useCallback( (integration?: IntegrationParamValue) => { + if (integration === "notion" || integration === "google-drive") { + void setAddDoc("connect") + return + } void setViewMode(integration ?? "integrations") }, - [setViewMode], + [setViewMode, setAddDoc], ) const handleOpenPlugins = useCallback(() => { @@ -544,6 +562,7 @@ export default function NewPage() { const isDashboardShell = viewMode === "dashboard" || (viewMode === "graph" && isMobile) const isGraphMode = viewMode === "graph" + const showBottomNav = isMobile && !isChatView && !!session return ( @@ -551,23 +570,23 @@ export default function NewPage() { className={cn( "relative flex min-h-dvh flex-col bg-[#05080D]", isGraphMode && "h-dvh overflow-hidden", + showBottomNav && + !isGraphMode && + "pb-[calc(5.5rem+env(safe-area-inset-bottom))]", )} > {showNovaBackdrop && ( - <> +
-
+
- +
)} {!session && viewMode === "mcp" ? ( @@ -711,6 +730,7 @@ export default function NewPage() { onNavigateToMemories={() => void setViewMode("list")} onNavigateToGraph={() => void setViewMode("graph")} onOpenDocument={handleOpenDocument} + onOpenToolDocument={handleOpenToolDocument} onHighlightsChat={handleHighlightsChat} onHighlightsShowRelated={handleHighlightsShowRelated} onResetHighlights={handleResetHighlights} @@ -722,14 +742,37 @@ export default function NewPage() { + {isDashboardShell && showBottomNav && ( +
+ )} {isDashboardShell && ( -
+
)} + {showBottomNav && ( + { + analytics.addDocumentModalOpened() + setAddDoc("note") + }} + onOpenSearch={() => { + analytics.searchOpened({ source: "header" }) + setIsSearchOpen(true) + }} + /> + )} + setAddDoc(null)} diff --git a/apps/web/app/(app)/settings/integrations/page.tsx b/apps/web/app/(app)/settings/integrations/page.tsx index 3984fb52..aa8a456b 100644 --- a/apps/web/app/(app)/settings/integrations/page.tsx +++ b/apps/web/app/(app)/settings/integrations/page.tsx @@ -8,8 +8,9 @@ export default function SettingsIntegrationsPage() { const searchParams = useSearchParams() useEffect(() => { - const qs = searchParams.toString() - router.replace(`/settings${qs ? `?${qs}` : ""}#integrations`) + const params = new URLSearchParams(searchParams.toString()) + params.set("view", "integrations") + router.replace(`/?${params.toString()}`) }, [router, searchParams]) return null diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx index ddf8a524..4d671977 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/settings/page.tsx @@ -2,12 +2,12 @@ import { Logo } from "@ui/assets/Logo" import { UserProfileMenu } from "@/components/user-profile-menu" import { useAuth } from "@lib/auth-context" -import { motion } from "motion/react" import NovaOrb from "@/components/nova/nova-orb" -import { useState, useEffect, useRef } from "react" +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" @@ -16,13 +16,42 @@ 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 } from "lucide-react" +import { + LogOut, + RotateCcw, + Trash2, + Sun, + LoaderIcon, + User as UserIcon, + Zap, + HelpCircle, + CreditCard, + ShieldAlert, + ChevronRight, + ChevronsUpDown, + Check, + Building2, +} from "lucide-react" import { authClient } from "@lib/auth" import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog" +import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover" import { useResetOrganization } from "@/hooks/use-reset-organization" import { useDeleteUserAccount } from "@/hooks/use-account-settings" +import { useCustomer } from "autumn-js/react" +import { useOrgSummaries } from "@/hooks/use-org-summaries" +import { + PLAN_DISPLAY_NAMES, + useTokenUsage, + type PlanType, +} from "@/hooks/use-token-usage" -const TABS = ["account", "integrations", "connections", "support"] as const +const TABS = [ + "account", + "billing", + "integrations", + "connections", + "support", +] as const type SettingsTab = (typeof TABS)[number] type NavItem = { @@ -32,134 +61,39 @@ type NavItem = { icon: React.ReactNode } -type DangerItem = { - id: "logout" | "reset" | "delete" - label: string - description: string - icon: React.ReactNode - color: "neutral" | "amber" | "red" -} - const NAV_ITEMS: NavItem[] = [ { id: "account", - label: "Account & Billing", - description: "Manage your profile, plan, usage and payments", - icon: ( - - ), + label: "Account", + description: "Your profile and organization", + icon: , + }, + { + id: "billing", + label: "Billing", + description: "Plan, usage and payments", + icon: , }, { id: "integrations", label: "Integrations", - description: "Save, sync and search memories across tools", - icon: , + description: "Save, sync and search across tools", + icon: , }, { id: "connections", label: "Connections & MCP", - description: "Sync with Google Drive, Notion, OneDrive and MCP client", - icon: ( - - ), + description: "Drive, Notion, OneDrive, MCP", + icon: , }, { id: "support", label: "Support & Help", - description: "Find answers or share feedback. We're here to help.", - icon: ( - - ), + description: "Get help or share feedback", + icon: , }, ] -const DANGER_ITEMS: DangerItem[] = [ - { - id: "logout", - label: "Log out", - description: "Sign out of your account on this device", - icon: , - color: "neutral", - }, - { - id: "reset", - label: "Reset data", - description: "Erase all memories, connections and spaces", - icon: , - color: "amber", - }, - { - id: "delete", - label: "Delete account", - description: "Permanently delete your account and all data", - icon: , - color: "red", - }, -] - -const DANGER_COLORS: Record< - DangerItem["color"], - { idle: string; hover: string; icon: string } -> = { - neutral: { - idle: "text-white/50", - hover: "hover:text-white", - icon: "text-white/40", - }, - amber: { - idle: "text-[#7A6030]", - hover: "hover:text-[#C7991B]", - icon: "text-[#7A6030]", - }, - red: { - idle: "text-[#6B2A2A]", - hover: "hover:text-[#C73B1B]", - icon: "text-[#6B2A2A]", - }, -} - function parseHashToTab(hash: string): SettingsTab { const cleaned = hash.replace("#", "").toLowerCase() return TABS.includes(cleaned as SettingsTab) @@ -167,30 +101,75 @@ function parseHashToTab(hash: string): SettingsTab { : "account" } -export function UserSupermemory({ name }: { name: string }) { +const ORG_PLAN_BADGE_STYLES: Record = { + free: "bg-[#2E353D] font-mono font-medium tracking-[0.12em] text-[#A3A3A3]", + pro: "bg-[#4BA0FA] font-bold tracking-[0.36px] text-[#00171A]", + 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 ( - - -
-

- {name.split(" ")[0]}'s -

-

- supermemory -

+ {PLAN_DISPLAY_NAMES[plan]} + + ) +} + +function resolveOrgPlan( + orgId: string, + isCurrent: boolean, + currentPlan: PlanType, + planByOrgId: Map, +): PlanType { + const fromSummary = planByOrgId.get(orgId) + if (fromSummary) return fromSummary + if (isCurrent) return currentPlan + return "free" +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function IdentityCard({ displayName }: { displayName: string }) { + const firstName = displayName?.split(" ")[0] || "" + + return ( +
+ +
+ +
+

+ {firstName ? `${firstName}'s` : "Your"} +

+

+ supermemory +

+
- +
) } export default function SettingsPage() { - const { user, org } = useAuth() + const { user, org, organizations, setActiveOrg } = useAuth() const [activeTab, setActiveTab] = useState("account") const hasInitialized = useRef(false) const router = useRouter() @@ -205,6 +184,40 @@ export default function SettingsPage() { const [deleteEmailConfirm, setDeleteEmailConfirm] = useState("") const deleteUserAccount = useDeleteUserAccount() + const [dangerMenuOpen, setDangerMenuOpen] = useState(false) + const [orgSwitcherOpen, setOrgSwitcherOpen] = useState(false) + const [switchingOrgId, setSwitchingOrgId] = useState(null) + const canSwitchOrg = (organizations?.length ?? 0) > 1 + + const autumn = useCustomer() + const { currentPlan } = useTokenUsage(autumn) + const { data: orgSummaries } = useOrgSummaries() + const planByOrgId = useMemo(() => { + const map = new Map() + for (const summary of orgSummaries ?? []) { + map.set(summary.orgId, summary.plan) + } + return map + }, [orgSummaries]) + const activeOrgPlan = org?.id + ? resolveOrgPlan(org.id, true, currentPlan, planByOrgId) + : currentPlan + + const handleOrgSwitch = async (orgSlug: string, orgId: string) => { + if (orgId === org?.id) { + setOrgSwitcherOpen(false) + return + } + setSwitchingOrgId(orgId) + try { + await setActiveOrg(orgSlug) + window.location.reload() + } catch (error) { + console.error("Failed to switch organization:", error) + setSwitchingOrgId(null) + } + } + const handleLogout = async () => { await authClient.signOut() router.push("/login") @@ -233,7 +246,6 @@ export default function SettingsPage() { setActiveTab(tab) analytics.settingsTabChanged({ tab }) - // If no hash or invalid hash, push #account if (!hash || !TABS.includes(hash.replace("#", "") as SettingsTab)) { window.history.pushState(null, "", "#account") } @@ -256,13 +268,10 @@ export default function SettingsPage() { user?.name || user?.email?.split("@")[0] || "" - const headerPossessive = headerDisplayName - ? `${headerDisplayName.split(" ")[0]}'s` - : "Your" return ( -
-
+
+
- -
-
-
-
- {!isMobile && ( - + {!isMobile && + (canSwitchOrg ? ( + + + + + + {[...(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 ( + + ) + })} + + + ) : ( +
- - - + + + + + {org?.name ?? "Personal"} + + +
+ ))} + +
+
+ +
+
+ {/* Left rail */} +
-
+ + + {/* Content */} +
{activeTab === "account" && } + {activeTab === "billing" && } {activeTab === "integrations" && } {activeTab === "connections" && } {activeTab === "support" && } -
+
@@ -509,6 +646,11 @@ export default function SettingsPage() { resetConfirmation !== confirmText || resetOrganization.isPending } + title={ + !confirmText || resetConfirmation !== confirmText + ? `Type "${confirmText || "your name"}" exactly to enable` + : undefined + } onClick={() => resetOrganization.mutate( { confirmation: confirmText }, @@ -589,6 +731,11 @@ export default function SettingsPage() { 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" > diff --git a/apps/web/app/(auth)/login/new/page.tsx b/apps/web/app/(auth)/login/new/page.tsx index 82d0fd48..0fcc1c01 100644 --- a/apps/web/app/(auth)/login/new/page.tsx +++ b/apps/web/app/(auth)/login/new/page.tsx @@ -1,524 +1,26 @@ -"use client" +import { redirect } from "next/navigation" -import { signIn, useSession } from "@lib/auth" -import { usePostHog } from "@lib/posthog" -import { TextSeparator } from "@ui/components/text-separator" -import { ExternalAuthButton } from "@ui/button/external-auth" -import { Button } from "@ui/components/button" -import { Badge } from "@ui/components/badge" -import { LabeledInput } from "@ui/input/labeled-input" -import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium" -import { Label1Regular } from "@ui/text/label/label-1-regular" -import { Title1Bold } from "@ui/text/title/title-1-bold" -import { InitialHeader } from "@/components/initial-header" -import { useRouter, useSearchParams } from "next/navigation" -import { useState, useEffect } from "react" -import { motion } from "motion/react" -import { dmSansClassName } from "@/lib/fonts" -import { cn } from "@lib/utils" -import { Logo } from "@ui/assets/Logo" - -function isMcpOAuthAuthorizeContext(sp: Pick): boolean { - return sp.get("response_type") === "code" && Boolean(sp.get("client_id")) -} - -function buildMcpAuthorizeResumeUrl( - sp: Pick, +function serializeSearchParams( + sp: Record, ): string { - const backend = - process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" - const p = new URLSearchParams(sp.toString()) - p.delete("redirect") - p.delete("error") - return `${backend}/api/auth/mcp/authorize?${p.toString()}` -} - -function AnimatedGradientBackground() { - return ( -
- - - -
- ) -} - -function LoginCard({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ) -} - -export default function LoginPage() { - const [email, setEmail] = useState("") - const [submittedEmail, setSubmittedEmail] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [isLoadingEmail, setIsLoadingEmail] = useState(false) - const [error, setError] = useState(null) - const [lastUsedMethod, setLastUsedMethod] = useState(null) - const router = useRouter() - - const posthog = usePostHog() - - const params = useSearchParams() - const { data: sessionData, isPending: sessionPending } = useSession() - - const oauthQueryForResume = params.toString() - - useEffect(() => { - if (sessionPending) return - if (!sessionData?.session) return - const sp = new URLSearchParams(oauthQueryForResume) - if (!isMcpOAuthAuthorizeContext(sp)) return - window.location.assign(buildMcpAuthorizeResumeUrl(sp)) - }, [sessionPending, sessionData?.session, oauthQueryForResume]) - - // Get redirect URL from query params - const redirectUrl = params.get("redirect") - - // Create callback URL that includes redirect parameter if provided - const getCallbackURL = () => { - const origin = window.location.origin - - if (isMcpOAuthAuthorizeContext(params)) { - return buildMcpAuthorizeResumeUrl(params) - } - - let finalUrl: URL - - if (redirectUrl) { - try { - finalUrl = new URL(redirectUrl, origin) - } catch { - finalUrl = new URL(origin) - } + const q = new URLSearchParams() + for (const [key, value] of Object.entries(sp)) { + if (value === undefined) continue + if (Array.isArray(value)) { + for (const v of value) q.append(key, v) } else { - finalUrl = new URL(origin) + q.set(key, value) } - - finalUrl.searchParams.set("extension-auth-success", "true") - return finalUrl.toString() } - - // Load last used method from localStorage on mount - useEffect(() => { - const savedMethod = localStorage.getItem("supermemory-last-login-method") - setLastUsedMethod(savedMethod) - }, []) - - // Record the pending login method (will be committed after successful auth) - function setPendingLoginMethod(method: string) { - try { - localStorage.setItem("supermemory-pending-login-method", method) - localStorage.setItem( - "supermemory-pending-login-timestamp", - String(Date.now()), - ) - } catch {} - } - - function isNetworkError(error: unknown): boolean { - if (!(error instanceof Error)) return false - const message = error.message.toLowerCase() - return ( - message.includes("load failed") || - message.includes("networkerror") || - message.includes("failed to fetch") || - message.includes("network request failed") - ) - } - - function getErrorMessage(error: unknown): string { - if (isNetworkError(error)) { - return "Network error. Please check your connection and try again." - } - if (error instanceof Error) { - return error.message - } - return "An unexpected error occurred. Please try again." - } - - // If we land back on this page with an error, clear any pending marker - useEffect(() => { - if (params.get("error")) { - try { - localStorage.removeItem("supermemory-pending-login-method") - localStorage.removeItem("supermemory-pending-login-timestamp") - } catch {} - } - }, [params]) - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setIsLoading(true) - setIsLoadingEmail(true) - setError(null) - - // Track login attempt - posthog.capture("login_attempt", { - method: "magic_link", - email_domain: email.split("@")[1] || "unknown", - }) - - const { error } = await signIn.magicLink({ - callbackURL: getCallbackURL(), - email, - }) - - if (error) { - console.error(error) - - // Track login failure - posthog.capture("login_failed", { - method: "magic_link", - error: error instanceof Error ? error.message : "Unknown error", - email_domain: email.split("@")[1] || "unknown", - is_network_error: isNetworkError(error), - }) - - setError(getErrorMessage(error)) - setIsLoading(false) - setIsLoadingEmail(false) - return - } - - setSubmittedEmail(email) - setPendingLoginMethod("magic_link") - posthog.capture("login_magic_link_sent", { - email_domain: email.split("@")[1] || "unknown", - }) - - setIsLoading(false) - setIsLoadingEmail(false) - } - - const handleSubmitToken = async (event: React.FormEvent) => { - event.preventDefault() - setIsLoading(true) - - const formData = new FormData(event.currentTarget) - const token = formData.get("token") as string - const callbackURL = getCallbackURL() - router.push( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`, - ) - } - - return ( -
- -
- -
-
-
- Never forget anything, anywhere -
-
with supermemory
-
- {submittedEmail ? ( - -
-
- - Almost there! - - - Click the magic link we've sent to{" "} - {submittedEmail}. - -
- - - -
- - - - -
-
- ) : ( - -
- {params.get("error") && ( -
- Error: {params.get("error")}. Please try again! -
- )} - -
- {process.env.NEXT_PUBLIC_HOST_ID === "supermemory" || - !process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? ( -
- - Google - - - - - - } - authProvider="Google" - className="w-full" - disabled={isLoading} - onClick={() => { - if (isLoading) return - setIsLoading(true) - posthog.capture("login_attempt", { - method: "social", - provider: "google", - }) - setPendingLoginMethod("google") - signIn - .social({ - callbackURL: getCallbackURL(), - provider: "google", - }) - .catch((err: unknown) => { - setError(getErrorMessage(err)) - }) - .finally(() => { - setIsLoading(false) - }) - }} - /> - {lastUsedMethod === "google" && ( -
- - Last used - -
- )} -
- ) : null} - {process.env.NEXT_PUBLIC_HOST_ID === "supermemory" || - !process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? ( -
- - Github - - - - - - - - - - } - authProvider="Github" - className="w-full" - disabled={isLoading} - onClick={() => { - if (isLoading) return - setIsLoading(true) - posthog.capture("login_attempt", { - method: "social", - provider: "github", - }) - setPendingLoginMethod("github") - signIn - .social({ - callbackURL: getCallbackURL(), - provider: "github", - }) - .catch((err: unknown) => { - setError(getErrorMessage(err)) - }) - .finally(() => { - setIsLoading(false) - }) - }} - /> - {lastUsedMethod === "github" && ( -
- - Last used - -
- )} -
- ) : null} -
- - - -
-
- ) => { - setEmail(e.target.value) - error && setError(null) - }, - required: true, - value: email, - }} - inputType="email" - /> - -
- - {lastUsedMethod === "magic_link" && ( -
- - Last used - -
- )} -
- - - - By continuing, you agree to our{" "} - - - Terms - {" "} - and{" "} - - Privacy Policy - - . - - -
-
-
- )} -
-
-
- ) + return q.toString() +} + +export default async function Page({ + searchParams, +}: { + searchParams: Promise> +}) { + const sp = await searchParams + const query = serializeSearchParams(sp) + redirect(query ? `/login?${query}` : "/login") } diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index a73f47ab..49fc56fc 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -1,26 +1,548 @@ -import { redirect } from "next/navigation" +"use client" -function serializeSearchParams( - sp: Record, +import { signIn, useSession } from "@lib/auth" +import { usePostHog } from "@lib/posthog" +import { TextSeparator } from "@ui/components/text-separator" +import { ExternalAuthButton } from "@ui/button/external-auth" +import { Button } from "@ui/components/button" +import { Badge } from "@ui/components/badge" +import { LabeledInput } from "@ui/input/labeled-input" +import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium" +import { Label1Regular } from "@ui/text/label/label-1-regular" +import { Title1Bold } from "@ui/text/title/title-1-bold" +import { InitialHeader } from "@/components/initial-header" +import { LoginToolsPanel } from "@/components/login-tools-panel" +import { useRouter, useSearchParams } from "next/navigation" +import { useState, useEffect } from "react" +import { motion } from "motion/react" +import { dmSansClassName } from "@/lib/fonts" +import { cn } from "@lib/utils" +import { Logo } from "@ui/assets/Logo" +import { resolveAuthRedirectUrl } from "@/lib/url-helpers" +import { Loader2 } from "lucide-react" + +function isMcpOAuthAuthorizeContext(sp: Pick): boolean { + return sp.get("response_type") === "code" && Boolean(sp.get("client_id")) +} + +function buildMcpAuthorizeResumeUrl( + sp: Pick, ): string { - const q = new URLSearchParams() - for (const [key, value] of Object.entries(sp)) { - if (value === undefined) continue - if (Array.isArray(value)) { - for (const v of value) q.append(key, v) - } else { - q.set(key, value) - } - } - return q.toString() + const backend = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + const p = new URLSearchParams(sp.toString()) + p.delete("redirect") + p.delete("error") + return `${backend}/api/auth/mcp/authorize?${p.toString()}` } -export default async function Page({ - searchParams, -}: { - searchParams: Promise> -}) { - const sp = await searchParams - const query = serializeSearchParams(sp) - redirect(query ? `/login/new?${query}` : "/login/new") +function LoginHeadline({ className }: { className?: string }) { + return ( +
+

+ Never forget anything, anywhere + with supermemory +

+

+ Save from Chrome, Notion, X — search it all in one place. +

+
+ ) +} + +function LastUsedBadge({ show }: { show: boolean }) { + if (!show) return null + return ( +
+ + Last used + +
+ ) +} +function LoginCard({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function LoginCardBody({ + children, + loadingMessage, +}: { + children: React.ReactNode + loadingMessage?: string | null +}) { + return ( +
+
{children}
+ {loadingMessage ? ( +
+ +

{loadingMessage}

+
+ ) : null} +
+ ) +} + +export default function LoginPage() { + const [email, setEmail] = useState("") + const [submittedEmail, setSubmittedEmail] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [isLoadingEmail, setIsLoadingEmail] = useState(false) + const [error, setError] = useState(null) + const [lastUsedMethod, setLastUsedMethod] = useState(null) + const router = useRouter() + + const posthog = usePostHog() + + const params = useSearchParams() + const { data: sessionData, isPending: sessionPending } = useSession() + + const oauthQueryForResume = params.toString() + const isRedirecting = !sessionPending && Boolean(sessionData?.session) + const isAuthResolving = sessionPending || isRedirecting + const loadingMessage = isAuthResolving + ? sessionPending + ? "Checking session…" + : "Redirecting…" + : isLoading + ? isLoadingEmail + ? "Sending login link…" + : "Redirecting…" + : null + + useEffect(() => { + if (sessionPending) return + if (!sessionData?.session) return + const sp = new URLSearchParams(oauthQueryForResume) + if (isMcpOAuthAuthorizeContext(sp)) { + window.location.assign(buildMcpAuthorizeResumeUrl(sp)) + return + } + const redirectUrl = params.get("redirect") + if (redirectUrl) { + window.location.assign( + resolveAuthRedirectUrl(redirectUrl, window.location.origin).toString(), + ) + return + } + router.replace("/") + }, [ + sessionPending, + sessionData?.session, + oauthQueryForResume, + params, + router, + ]) + + // Get redirect URL from query params + const redirectUrl = params.get("redirect") + + // Create callback URL that includes redirect parameter if provided + const getCallbackURL = () => { + const origin = window.location.origin + + if (isMcpOAuthAuthorizeContext(params)) { + return buildMcpAuthorizeResumeUrl(params) + } + + const finalUrl = resolveAuthRedirectUrl(redirectUrl, origin) + + finalUrl.searchParams.set("extension-auth-success", "true") + return finalUrl.toString() + } + + // Load last used method from localStorage on mount + useEffect(() => { + const savedMethod = localStorage.getItem("supermemory-last-login-method") + setLastUsedMethod(savedMethod) + }, []) + + // Record the pending login method (will be committed after successful auth) + function setPendingLoginMethod(method: string) { + try { + localStorage.setItem("supermemory-pending-login-method", method) + localStorage.setItem( + "supermemory-pending-login-timestamp", + String(Date.now()), + ) + } catch {} + } + + function isNetworkError(error: unknown): boolean { + if (!(error instanceof Error)) return false + const message = error.message.toLowerCase() + return ( + message.includes("load failed") || + message.includes("networkerror") || + message.includes("failed to fetch") || + message.includes("network request failed") + ) + } + + function getErrorMessage(error: unknown): string { + if (isNetworkError(error)) { + return "Network error. Please check your connection and try again." + } + if (error instanceof Error) { + return error.message + } + return "An unexpected error occurred. Please try again." + } + + // If we land back on this page with an error, clear any pending marker + useEffect(() => { + if (params.get("error")) { + try { + localStorage.removeItem("supermemory-pending-login-method") + localStorage.removeItem("supermemory-pending-login-timestamp") + } catch {} + } + }, [params]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setIsLoading(true) + setIsLoadingEmail(true) + setError(null) + + // Track login attempt + posthog.capture("login_attempt", { + method: "magic_link", + email_domain: email.split("@")[1] || "unknown", + }) + + const { error } = await signIn.magicLink({ + callbackURL: getCallbackURL(), + email, + }) + + if (error) { + console.error(error) + + // Track login failure + posthog.capture("login_failed", { + method: "magic_link", + error: error instanceof Error ? error.message : "Unknown error", + email_domain: email.split("@")[1] || "unknown", + is_network_error: isNetworkError(error), + }) + + setError(getErrorMessage(error)) + setIsLoading(false) + setIsLoadingEmail(false) + return + } + + setSubmittedEmail(email) + setPendingLoginMethod("magic_link") + posthog.capture("login_magic_link_sent", { + email_domain: email.split("@")[1] || "unknown", + }) + + setIsLoading(false) + setIsLoadingEmail(false) + } + + const handleSubmitToken = async (event: React.FormEvent) => { + event.preventDefault() + setIsLoading(true) + + const formData = new FormData(event.currentTarget) + const token = formData.get("token") as string + const callbackURL = getCallbackURL() + router.push( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`, + ) + } + + return ( +
+
+
+
+
+
+ +
+ +
+
+ +
+
+
+ + + + {submittedEmail ? ( +
+
+ + Almost there! + + + Click the magic link we've sent to{" "} + + {submittedEmail} + + . + +
+ + + +
+ + + + +
+ ) : ( +
+ {params.get("error") && ( +
+ Error: {params.get("error")}. Please try again! +
+ )} + +
+ {process.env.NEXT_PUBLIC_HOST_ID === "supermemory" || + !process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? ( +
+ + + Google + + + + + + } + authProvider="Google" + className="w-full" + disabled={Boolean(loadingMessage)} + onClick={() => { + if (loadingMessage) return + setIsLoading(true) + posthog.capture("login_attempt", { + method: "social", + provider: "google", + }) + setPendingLoginMethod("google") + signIn + .social({ + callbackURL: getCallbackURL(), + provider: "google", + }) + .catch((err: unknown) => { + setError(getErrorMessage(err)) + setIsLoading(false) + }) + }} + /> +
+ ) : null} + {process.env.NEXT_PUBLIC_HOST_ID === "supermemory" || + !process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? ( +
+ + + Github + + + + + + + + + + } + authProvider="Github" + className="w-full" + disabled={Boolean(loadingMessage)} + onClick={() => { + if (loadingMessage) return + setIsLoading(true) + posthog.capture("login_attempt", { + method: "social", + provider: "github", + }) + setPendingLoginMethod("github") + signIn + .social({ + callbackURL: getCallbackURL(), + provider: "github", + }) + .catch((err: unknown) => { + setError(getErrorMessage(err)) + setIsLoading(false) + }) + }} + /> +
+ ) : null} +
+ + + +
+
+ , + ) => { + setEmail(e.target.value) + error && setError(null) + }, + required: true, + value: email, + }} + inputType="email" + /> + +
+ + +
+ + + + By continuing, you agree to our{" "} + + + Terms + {" "} + and{" "} + + Privacy Policy + + . + + +
+
+ )} +
+
+
+
+
+
+ ) } diff --git a/apps/web/app/api/onboarding/extract-content/route.ts b/apps/web/app/api/onboarding/extract-content/route.ts index 6cdb40d5..9322f324 100644 --- a/apps/web/app/api/onboarding/extract-content/route.ts +++ b/apps/web/app/api/onboarding/extract-content/route.ts @@ -9,8 +9,22 @@ interface ExaApiResponse { results: ExaContentResult[] } +const exaApiKey = process.env.EXA_API_KEY +if (!exaApiKey) { + console.error( + "EXA_API_KEY is not configured; /api/onboarding/extract-content will return 503", + ) +} + export async function POST(request: Request) { try { + if (!exaApiKey) { + return Response.json( + { error: "Content extraction is unavailable" }, + { status: 503 }, + ) + } + const { urls } = await request.json() if (!Array.isArray(urls) || urls.length === 0) { @@ -30,7 +44,7 @@ export async function POST(request: Request) { const response = await fetch("https://api.exa.ai/contents", { method: "POST", headers: { - "x-api-key": process.env.EXA_API_KEY ?? "", + "x-api-key": exaApiKey, "Content-Type": "application/json", }, body: JSON.stringify({ diff --git a/apps/web/app/auth/connect/page.tsx b/apps/web/app/auth/connect/page.tsx index 609c6e9a..37f61d3d 100644 --- a/apps/web/app/auth/connect/page.tsx +++ b/apps/web/app/auth/connect/page.tsx @@ -90,6 +90,17 @@ const PLUGIN_INFO: Record = { ], icon: "/images/plugins/cursor.svg", }, + codex: { + name: "OpenAI Codex", + description: + "Persistent memory for OpenAI Codex CLI. Remembers your coding context, patterns, and decisions across sessions.", + features: [ + "Auto-recalls relevant context before each prompt", + "Captures coding decisions and patterns automatically", + "Builds persistent user profile across projects", + ], + icon: "/images/plugins/codex.svg", + }, } function getPluginName(client: string): string { diff --git a/apps/web/components/add-document/file.tsx b/apps/web/components/add-document/file.tsx index 92e56751..beb31878 100644 --- a/apps/web/components/add-document/file.tsx +++ b/apps/web/components/add-document/file.tsx @@ -194,9 +194,12 @@ export function FileContent({ )} >
-

- Upload files (images, PDF, documents, sheets, markdown) -

+
+

Upload files

+

+ Images, PDF, documents, sheets, markdown +

+