Self-hosted dashboard and admin auth (#4837)

Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
Gabriel Stein
2026-04-23 19:06:36 +05:30
committed by GitHub
co-authored by kartik-mem0
parent 15feaa8ac4
commit db8ac61713
252 changed files with 20275 additions and 331 deletions
@@ -0,0 +1,44 @@
import "@/styles/globals.css";
import React from "react";
import { Inter, InterDisplay, Roboto, Fustat, DMMono } from "../(root)/fonts";
import { cn } from "@/lib/utils";
import { ThemeProvider } from "@/components/theme-provider";
import { AuthProvider } from "@/lib/auth";
export const metadata = {
title: "Mem0 - Log in",
description: "Log in to Mem0",
};
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="en"
className={cn(
Fustat.variable,
InterDisplay.variable,
Inter.variable,
Roboto.variable,
DMMono.variable,
)}
suppressHydrationWarning
>
<body className="font-fustat" suppressHydrationWarning>
<AuthProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</AuthProvider>
</body>
</html>
);
}
@@ -0,0 +1,233 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
import { Check, Copy } from "lucide-react";
import { CopyToClipboard } from "react-copy-to-clipboard";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuth } from "@/hooks/use-auth";
import { getErrorMessage } from "@/lib/error-message";
import { isValidEmail } from "@/lib/validators";
const RESET_COMMAND =
"make reset-admin-password EMAIL=<your-email> PASSWORD=<new-password>";
export default function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const { user, isLoading, login } = useAuth();
const { resolvedTheme } = useTheme();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [mounted, setMounted] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!isLoading && user) {
router.push(searchParams.get("next") || "/dashboard/requests");
}
}, [user, isLoading, router, searchParams]);
const emailValid = isValidEmail(email);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!emailValid) {
setError("Enter a valid email address.");
return;
}
setSubmitting(true);
try {
await login(email, password);
router.push(searchParams.get("next") || "/dashboard/requests");
} catch (err) {
setError(getErrorMessage(err, "Login failed"));
} finally {
setSubmitting(false);
}
};
return (
<div className="flex min-h-screen">
<div className="flex-1 bg-surface-default-primary flex items-center justify-center p-8">
<div className="w-full max-w-md">
<div className="flex justify-center mb-2">
{mounted && (
<Image
src={
resolvedTheme === "dark"
? "/images/logos/logo-light.png"
: "/images/logos/logo-dark.png"
}
alt="Mem0"
width={41}
height={41}
/>
)}
</div>
<h1 className="text-2xl font-semibold text-onSurface-default-primary text-center mb-6 font-fustat">
Sign in to Mem0
</h1>
<div className="flex flex-col gap-4 border p-8 border-memBorder-primary rounded-xl">
{error && (
<p className="text-sm text-onSurface-danger-primary bg-surface-danger-primary px-3 py-2 rounded">
{error}
</p>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="space-y-1.5">
<Label htmlFor="login-email">Email</Label>
<Input
id="login-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@company.com"
required
autoFocus
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="login-password">Password</Label>
<Input
id="login-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button
type="submit"
disabled={submitting || !emailValid || !password}
variant="default"
size="lg"
className="w-full"
>
{submitting ? "Signing in..." : "Sign in"}
</Button>
</form>
<Dialog>
<DialogTrigger asChild>
<button
type="button"
className="text-xs text-onSurface-default-tertiary hover:text-onSurface-default-primary underline underline-offset-4 self-center"
>
Forgot password?
</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Reset your admin password</DialogTitle>
<DialogDescription>
Run this command on the server host. It overwrites the
existing password; anyone already signed in stays signed in
until their session expires.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input
readOnly
value={RESET_COMMAND}
className="font-mono text-xs"
/>
<CopyToClipboard
text={RESET_COMMAND}
onCopy={() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
<Button variant="outline" size="icon">
{copied ? (
<Check className="size-4" />
) : (
<Copy className="size-4" />
)}
</Button>
</CopyToClipboard>
</div>
</DialogContent>
</Dialog>
</div>
</div>
</div>
<div className="relative hidden h-screen flex-1 items-center justify-center overflow-hidden bg-gradient-to-b from-[#31275A] to-[#5C49A3] px-10 lg:flex">
<div className="pointer-events-none absolute inset-0 bg-[url('/images/dither.svg')] bg-bottom bg-no-repeat bg-contain" />
<div className="relative z-10 flex w-full max-w-[564px] flex-col items-center gap-20 text-center text-white">
<div className="w-full space-y-5">
<p className="typo-h3 text-white">
&quot;Mem0 allowed us to unlock true personalized tutoring for
every student, and it took us just a weekend to integrate.&quot;
</p>
<div className="flex flex-col items-center gap-[7px]">
<div className="flex flex-col items-center gap-1">
<p className="typo-body-sm text-white">Michael Tong</p>
<p className="typo-body-xs text-white">CTO, RevisionDojo</p>
</div>
<Image
src="/images/micheal.png"
alt="Michael Tong"
width={32}
height={32}
className="size-8 rounded-full object-cover"
/>
</div>
</div>
<div className="flex w-full flex-col items-center gap-3">
<p className="typo-body text-white">Trusted by 100k+ Developers</p>
<div className="flex items-center justify-center gap-8 text-white">
<div className="h-6 shrink-0">
<Image
src="/images/logos/aws.svg"
alt="AWS"
width={41}
height={24}
className="size-full object-contain"
/>
</div>
<div className="h-5 shrink-0">
<Image
src="/images/logos/nvidia.svg"
alt="NVIDIA"
width={109}
height={21}
className="size-full object-contain"
/>
</div>
<div className="h-[21px] shrink-0">
<Image
src="/images/vercel.png"
alt="Vercel"
width={66}
height={21}
className="size-full object-contain"
/>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,10 @@
import { Suspense } from "react";
import LoginForm from "./login-form";
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginForm />
</Suspense>
);
}
@@ -0,0 +1,49 @@
"use client";
import { useEffect } from "react";
import { useRouter, usePathname } from "next/navigation";
import ThemeAwareLogo from "@/components/misc/theme-aware-logo";
import { LinearProgress } from "@/components/ui/linearProgress";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useAuth } from "@/hooks/use-auth";
export const SIDEBAR_WIDTH = 180;
export const COLLAPSED_SIDEBAR_WIDTH = 64;
export const COLLAPSED_SIDEBAR_PADDING = 16;
export const COLLAPSED_SIDEBAR_WIDTH_WITHOUT_PADDING =
COLLAPSED_SIDEBAR_WIDTH - COLLAPSED_SIDEBAR_PADDING;
function AuthLoadingState() {
return (
<div className="flex h-screen w-screen flex-col items-center justify-center">
<ThemeAwareLogo />
<LinearProgress value={66} className="mt-8 h-1 w-[180px]" />
</div>
);
}
export const ClientLayout: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const pathname = usePathname();
const router = useRouter();
const { user, isLoading } = useAuth();
const isPublicPage =
pathname.startsWith("/login") || pathname.startsWith("/setup");
useEffect(() => {
if (!isPublicPage && !isLoading && !user) {
router.replace("/login");
}
}, [isLoading, isPublicPage, router, user]);
if (isLoading && !isPublicPage) {
return <AuthLoadingState />;
}
if (!isPublicPage && !user) {
return <AuthLoadingState />;
}
return <TooltipProvider delayDuration={0}>{children}</TooltipProvider>;
};
@@ -0,0 +1,55 @@
"use client";
import React from "react";
import { ThemeProvider } from "@/components/theme-provider";
import "@/styles/globals.css";
import { ClientLayout } from "./clientLayout";
import { cn } from "@/lib/utils";
import { Inter, InterDisplay, Roboto, Fustat, DMMono } from "./fonts";
import { Provider } from "react-redux";
import store from "@/store/store";
import { AuthProvider } from "@/lib/auth";
import dynamic from "next/dynamic";
const Toaster = dynamic(
() =>
import("@/components/ui/sonner").then((mod) => ({ default: mod.Toaster })),
{
ssr: false,
},
);
export function DashboardClientLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={cn(
Inter.className,
InterDisplay.variable,
Roboto.variable,
Fustat.variable,
DMMono.variable,
)}
suppressHydrationWarning
>
<Provider store={store}>
<AuthProvider>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem
disableTransitionOnChange
>
<ClientLayout>{children}</ClientLayout>
<Toaster />
</ThemeProvider>
</AuthProvider>
</Provider>
</body>
</html>
);
}
@@ -0,0 +1,54 @@
"use client";
import { LockedPage } from "@/components/self-hosted/locked-page";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
function AnalyticsMockup() {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[
{ label: "Total Operations", value: "12,847" },
{ label: "Avg Latency", value: "142ms" },
{ label: "Success Rate", value: "99.7%" },
].map((stat) => (
<Card key={stat.label} className="border-memBorder-primary">
<CardContent className="p-4">
<p className="text-xs text-onSurface-default-tertiary">
{stat.label}
</p>
<p className="text-2xl font-semibold mt-1">{stat.value}</p>
</CardContent>
</Card>
))}
</div>
<Card className="border-memBorder-primary">
<CardHeader className="pb-2">
<CardTitle className="text-sm">Operations over time</CardTitle>
</CardHeader>
<CardContent className="p-4">
<div className="h-[200px] flex items-end gap-1">
{[40, 65, 45, 80, 55, 90, 70, 85, 60, 95, 75, 50].map((h, i) => (
<div
key={i}
className="flex-1 bg-surface-default-brand rounded-t"
style={{ height: `${h}%` }}
/>
))}
</div>
</CardContent>
</Card>
</div>
);
}
export default function AnalyticsPage() {
return (
<LockedPage
title="Analytics"
description="Track memory operations, latency, and usage patterns over time."
previewContent={<AnalyticsMockup />}
utmMedium="dashboard-locked-analytics"
/>
);
}
@@ -0,0 +1,244 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DataTable } from "@/components/shared/data-table";
import { TableSkeleton } from "@/components/shared/table-skeleton";
import { EmptyState } from "@/components/self-hosted/empty-state";
import DeleteConfirmationModal from "@/components/ui/delete-confirmation-modal";
import { api } from "@/utils/api";
import { API_KEY_ENDPOINTS } from "@/utils/api-endpoints";
import { toast } from "@/components/ui/use-toast";
import { UpgradeBanner } from "@/components/self-hosted/upgrade-banner";
import { Plus, Copy, Check, Trash2 } from "lucide-react";
import { CopyToClipboard } from "react-copy-to-clipboard";
import { format } from "date-fns";
import { getErrorMessage } from "@/lib/error-message";
import { useApiQuery } from "@/hooks/use-api-query";
import { ApiKey, ApiKeyCreateResponse } from "@/types/api";
export default function ApiKeysPage() {
const [createOpen, setCreateOpen] = useState(false);
const [newLabel, setNewLabel] = useState("");
const [newKey, setNewKey] = useState("");
const [copied, setCopied] = useState(false);
const [keyToRevoke, setKeyToRevoke] = useState<ApiKey | null>(null);
const {
data: keys = [],
isLoading,
refetch,
} = useApiQuery<ApiKey[]>(
async () => {
const res = await api.get<ApiKey[]>(API_KEY_ENDPOINTS.BASE);
return res.data ?? [];
},
{ errorToast: "Failed to load API keys", initialData: [] },
);
const handleCreate = async () => {
try {
const res = await api.post<ApiKeyCreateResponse>(API_KEY_ENDPOINTS.BASE, {
label: newLabel,
});
setNewKey(res.data.key);
void refetch();
} catch (error) {
toast({
title: "Failed to create key",
description: getErrorMessage(error),
variant: "destructive",
});
}
};
const handleRevoke = async () => {
if (!keyToRevoke) return;
try {
await api.delete(API_KEY_ENDPOINTS.BY_ID(keyToRevoke.id));
toast({ title: "API key revoked", variant: "success" });
setKeyToRevoke(null);
void refetch();
} catch (error) {
toast({
title: "Failed to revoke key",
description: getErrorMessage(error),
variant: "destructive",
});
}
};
const handleDialogClose = (open: boolean) => {
if (!open) {
setNewKey("");
setNewLabel("");
setCopied(false);
}
setCreateOpen(open);
};
const columns = [
{ key: "label" as keyof ApiKey, label: "Label", width: 150 },
{
key: "key_prefix" as keyof ApiKey,
label: "Key",
width: 120,
render: (value: string) => (
<code className="text-xs font-mono">{value}...</code>
),
},
{
key: "created_at" as keyof ApiKey,
label: "Created",
width: 120,
render: (value: string) => format(new Date(value), "MMM d, yyyy"),
},
{
key: "last_used_at" as keyof ApiKey,
label: "Last Used",
width: 120,
render: (value: string | null) =>
value ? format(new Date(value), "MMM d, yyyy") : "Never",
},
{
key: "id" as keyof ApiKey,
label: "",
width: 40,
render: (_: string, row: ApiKey) => (
<Button
variant="ghost"
size="icon"
onClick={() => setKeyToRevoke(row)}
className="size-7"
>
<Trash2 className="size-3.5 text-onSurface-danger-primary" />
</Button>
),
},
];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold font-fustat">API Keys</h1>
<Dialog open={createOpen} onOpenChange={handleDialogClose}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="size-4 mr-1" /> Create Key
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create API Key</DialogTitle>
</DialogHeader>
{!newKey ? (
<div className="space-y-4 mt-2">
<div className="space-y-2">
<Label htmlFor="api-key-label">Label</Label>
<Input
id="api-key-label"
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
placeholder="e.g. Production"
/>
</div>
<Button
onClick={handleCreate}
disabled={!newLabel}
className="w-full"
>
Create
</Button>
</div>
) : (
<div className="space-y-4 mt-2">
<div className="space-y-2">
<Label htmlFor="api-key-new">Your API Key</Label>
<div className="flex gap-2">
<Input
id="api-key-new"
value={newKey}
readOnly
className="font-mono text-sm"
/>
<CopyToClipboard
text={newKey}
onCopy={() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
<Button variant="outline" size="icon">
{copied ? (
<Check className="size-4" />
) : (
<Copy className="size-4" />
)}
</Button>
</CopyToClipboard>
</div>
<p className="text-xs text-onSurface-danger-primary">
Save this key -- you won&apos;t see it again.
</p>
</div>
<Button
onClick={() => handleDialogClose(false)}
className="w-full"
>
Done
</Button>
</div>
)}
</DialogContent>
</Dialog>
</div>
{keys.length >= 3 && (
<UpgradeBanner
id="api-keys-3"
message="Managing multiple apps? Cloud offers project-based isolation."
ctaLabel="Explore Cloud"
ctaUrl="https://app.mem0.ai?utm_source=oss&utm_medium=dashboard-api-keys"
variant="cloud"
/>
)}
{isLoading ? (
<TableSkeleton rows={3} columns={4} />
) : keys.length === 0 ? (
<EmptyState
title="No API keys yet"
description="Create your first API key to start using the Mem0 API."
/>
) : (
<Card className="border-memBorder-primary overflow-hidden">
<DataTable
data={keys}
columns={columns}
getRowKey={(row) => row.id}
/>
</Card>
)}
<DeleteConfirmationModal
isOpen={!!keyToRevoke}
onClose={() => setKeyToRevoke(null)}
onConfirm={handleRevoke}
title="Revoke API key"
description="Applications using this key will immediately stop working. This cannot be undone."
itemName={keyToRevoke?.label ?? ""}
confirmButtonText="Revoke"
/>
</div>
);
}
@@ -0,0 +1,36 @@
"use client";
import { LockedPage } from "@/components/self-hosted/locked-page";
import { Card, CardContent } from "@/components/ui/card";
function CategoriesMockup() {
const categories = ["Health", "Preferences", "Work"];
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{categories.map((cat) => (
<Card key={cat} className="border-memBorder-primary">
<CardContent className="p-4">
<p className="text-sm font-medium mb-3">{cat}</p>
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-8 bg-surface-default-secondary rounded mb-2"
/>
))}
</CardContent>
</Card>
))}
</div>
);
}
export default function CategoriesPage() {
return (
<LockedPage
title="Custom Categories"
description="Organize memories by domain -- health, preferences, work, and more."
previewContent={<CategoriesMockup />}
utmMedium="dashboard-locked-categories"
/>
);
}
@@ -0,0 +1,249 @@
"use client";
import * as React from "react";
import Link from "next/link";
import {
Activity,
ChartLine,
ChevronDown,
FolderInput,
GalleryVerticalEnd,
KeyRound,
Settings,
Tags,
Users,
WebhookIcon,
Wrench,
} from "lucide-react";
import { useSelector } from "react-redux";
import { RootState } from "@/store/store";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarGroupLabel,
} from "@/components/ui/sidebar";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
export function MainNav({
className,
...props
}: React.HTMLAttributes<HTMLElement>) {
const pathname = usePathname();
const isSidebarCollapsed = useSelector(
(state: RootState) => state.layout.isSidebarCollapsed,
);
const [isCloudOpen, setIsCloudOpen] = React.useState(true);
return (
<Sidebar
collapsible={isSidebarCollapsed ? "icon" : undefined}
className={cn(className, "border-r-0 w-full mb-0 bg-transparent")}
{...props}
>
<SidebarContent>
<SidebarGroup>
<SidebarMenu className="gap-0">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-0">
{!isSidebarCollapsed && (
<SidebarGroupLabel className="mb-0">
ACTIVITY
</SidebarGroupLabel>
)}
{[
{
title: "Requests",
url: "/dashboard/requests",
icon: Activity,
active: pathname === "/dashboard/requests",
},
{
title: "Memories",
url: "/dashboard/memories",
icon: GalleryVerticalEnd,
active: pathname === "/dashboard/memories",
},
{
title: "Entities",
url: "/dashboard/entities",
icon: Users,
active: pathname === "/dashboard/entities",
},
].map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
collapsed={isSidebarCollapsed}
active={item.active}
tooltip={isSidebarCollapsed ? item.title : undefined}
>
<Link
href={item.url}
className={cn(
"flex items-center w-full",
isSidebarCollapsed
? "justify-center mx-auto"
: "gap-1.5",
)}
>
<item.icon className="size-4 shrink-0" />
{!isSidebarCollapsed && <span>{item.title}</span>}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</div>
{isSidebarCollapsed && (
<div className="h-[1px] w-full bg-memBorder-primary my-2" />
)}
<Collapsible
open={isCloudOpen}
onOpenChange={setIsCloudOpen}
className="flex flex-col gap-0"
>
{!isSidebarCollapsed && (
<CollapsibleTrigger asChild>
<SidebarGroupLabel className="cursor-pointer mb-0">
CLOUD FEATURES
<ChevronDown
className={cn(
"size-3 transition-transform duration-200",
isCloudOpen ? "" : "-rotate-90",
)}
/>
</SidebarGroupLabel>
</CollapsibleTrigger>
)}
<CollapsibleContent className="flex flex-col gap-0">
{[
{
title: "Categories",
url: "/dashboard/categories",
icon: Tags,
},
{
title: "Webhooks",
url: "/dashboard/webhooks",
icon: WebhookIcon,
},
{
title: "Analytics",
url: "/dashboard/analytics",
icon: ChartLine,
},
{
title: "Export",
url: "/dashboard/export",
icon: FolderInput,
},
].map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
collapsed={isSidebarCollapsed}
active={pathname === item.url}
tooltip={isSidebarCollapsed ? item.title : undefined}
>
<Link
href={item.url}
className={cn(
"flex items-center w-full",
isSidebarCollapsed
? "justify-center mx-auto"
: "gap-1.5",
)}
>
<item.icon className="size-4 shrink-0" />
{!isSidebarCollapsed && (
<>
<span>{item.title}</span>
<Badge
variant="outline"
className="ml-auto text-memGold-600 border-memGold-300 typo-caption-sm px-1.5 py-0"
>
PRO
</Badge>
</>
)}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</CollapsibleContent>
</Collapsible>
{isSidebarCollapsed && (
<div className="h-[1px] w-full bg-memBorder-primary my-2" />
)}
<div className="flex flex-col gap-0">
{!isSidebarCollapsed && (
<SidebarGroupLabel className="mb-0">
ACCOUNT
</SidebarGroupLabel>
)}
{[
{
title: "API Keys",
url: "/dashboard/api-keys",
icon: KeyRound,
active: pathname === "/dashboard/api-keys",
},
{
title: "Configuration",
url: "/dashboard/configuration",
icon: Wrench,
active: pathname === "/dashboard/configuration",
},
{
title: "Settings",
url: "/dashboard/settings",
icon: Settings,
active: pathname === "/dashboard/settings",
},
].map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
collapsed={isSidebarCollapsed}
active={item.active}
tooltip={isSidebarCollapsed ? item.title : undefined}
>
<Link
href={item.url}
className={cn(
"flex items-center w-full",
isSidebarCollapsed
? "justify-center mx-auto"
: "gap-1.5",
)}
>
<item.icon className="size-4 shrink-0" />
{!isSidebarCollapsed && <span>{item.title}</span>}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</div>
</div>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarRail />
</Sidebar>
);
}
@@ -0,0 +1,174 @@
"use client";
import { MainNav } from "./main-nav";
import { PanelRight, LogOut, Settings, HelpCircle } from "lucide-react";
import { useCallback } from "react";
import {
COLLAPSED_SIDEBAR_WIDTH,
COLLAPSED_SIDEBAR_WIDTH_WITHOUT_PADDING,
SIDEBAR_WIDTH,
} from "../../clientLayout";
import { useDispatch, useSelector } from "react-redux";
import { cn } from "@/lib/utils";
import { RootState } from "@/store/store";
import { toggleSidebar } from "@/store/reducers/layoutReducer";
import { useAuth } from "@/hooks/use-auth";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import Link from "next/link";
import { Building2 } from "lucide-react";
export default function NavWrapper() {
const dispatch = useDispatch();
const isSidebarCollapsed = useSelector(
(state: RootState) => state.layout.isSidebarCollapsed,
);
const { user, logout } = useAuth();
const instanceName = process.env.NEXT_PUBLIC_INSTANCE_NAME || "Mem0";
const handleToggle = useCallback(() => {
dispatch(toggleSidebar());
}, [dispatch]);
return (
<>
<div
className="fixed top-0 left-0 h-full flex justify-between flex-col overflow-hidden transition-all duration-300 ease-in-out z-30 bg-transparent"
style={{
width: isSidebarCollapsed ? COLLAPSED_SIDEBAR_WIDTH : SIDEBAR_WIDTH,
}}
>
<div className="flex flex-col flex-1 min-h-0 items-start gap-5 px-3 py-3 overflow-y-auto overflow-x-hidden">
<div
className={cn(
"relative flex w-full",
isSidebarCollapsed ? "p-0 justify-center" : "",
)}
>
<div
className={cn(
"flex items-center w-full",
isSidebarCollapsed ? "justify-center" : "gap-2",
)}
>
<div className="flex items-center justify-center size-7 rounded-md bg-surface-default-tertiary shrink-0">
<Building2 className="size-4 text-onSurface-default-primary" />
</div>
{!isSidebarCollapsed && (
<span className="typo-body-xs text-onSurface-default-primary truncate text-left flex-1 min-w-0">
{instanceName}
</span>
)}
</div>
</div>
<MainNav className="w-full" />
</div>
{!isSidebarCollapsed && (
<div className="flex flex-col shrink-0">
<div className="mx-3 px-0 py-3 border-t border-memBorder-primary">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-2 w-full text-left hover:bg-surface-default-secondary-hover rounded-md p-1.5 transition-colors">
<div className="grid size-7 place-items-center rounded-md bg-surface-default-tertiary text-onSurface-default-secondary text-xs font-semibold shrink-0">
{user?.name?.charAt(0).toUpperCase() || "?"}
</div>
<div className="flex flex-col min-w-0">
<span className="typo-body-xs text-onSurface-default-primary truncate">
{user?.name}
</span>
<span className="typo-caption-sm text-onSurface-default-tertiary truncate">
{user?.email}
</span>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="top"
className="w-56 font-fustat bg-surface-default-primary border-memBorder-secondary"
>
<div className="px-2 py-1.5">
<p className="typo-body-sm text-onSurface-default-primary">
{user?.name}
</p>
<p className="typo-body-xs text-onSurface-default-tertiary">
{user?.email}
</p>
</div>
<DropdownMenuSeparator className="bg-memBorder-primary" />
<DropdownMenuItem
asChild
className="typo-body-sm text-onSurface-default-primary hover:bg-surface-default-tertiary-hover focus:bg-surface-default-tertiary-hover cursor-pointer"
>
<Link href="/dashboard/settings">
<Settings className="size-4 mr-2" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-memBorder-primary" />
<DropdownMenuItem
onClick={logout}
className="typo-body-sm text-onSurface-default-primary hover:bg-surface-default-tertiary-hover focus:bg-surface-default-tertiary-hover cursor-pointer"
>
<LogOut className="size-4 mr-2" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
</div>
<div
className="bg-transparent left-0 top-0 fixed flex justify-between items-center pr-4 h-12 font-fustat z-20"
style={{
width: `calc(100% - ${isSidebarCollapsed ? COLLAPSED_SIDEBAR_WIDTH_WITHOUT_PADDING + 12 : SIDEBAR_WIDTH + 12}px)`,
left: `${isSidebarCollapsed ? COLLAPSED_SIDEBAR_WIDTH_WITHOUT_PADDING + 12 : SIDEBAR_WIDTH + 12}px`,
}}
>
<div className="flex items-center">
<button
type="button"
onClick={handleToggle}
className="cursor-pointer text-onSurface-default-tertiary hover:text-onSurface-default-secondary"
aria-label={
isSidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"
}
>
<PanelRight className="size-4" />
</button>
</div>
<div className="flex items-center gap-3">
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://docs.mem0.ai/open-source/overview"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center text-onSurface-default-tertiary hover:text-onSurface-default-secondary"
>
<HelpCircle className="size-4 shrink-0" />
</a>
</TooltipTrigger>
<TooltipContent>Documentation</TooltipContent>
</Tooltip>
</div>
</div>
</>
);
}
@@ -0,0 +1,236 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "@/components/ui/use-toast";
import { UpgradeBanner } from "@/components/self-hosted/upgrade-banner";
import { getErrorMessage } from "@/lib/error-message";
import { api } from "@/utils/api";
import { MEMORY_ENDPOINTS } from "@/utils/api-endpoints";
import {
buildProviderConfig,
getEffectiveConfig,
} from "@/utils/self-hosted-config";
import { useAuth } from "@/hooks/use-auth";
import { useApiQuery } from "@/hooks/use-api-query";
type BundledProviders = {
llm: string[];
embedder: string[];
};
export default function ConfigurationPage() {
const { isAdmin } = useAuth();
const [isSaving, setIsSaving] = useState(false);
const [llmProvider, setLlmProvider] = useState("");
const [llmModel, setLlmModel] = useState("");
const [llmApiKey, setLlmApiKey] = useState("");
const [embedderProvider, setEmbedderProvider] = useState("");
const [embedderModel, setEmbedderModel] = useState("");
const { data: config, isLoading: isPrefilling } = useApiQuery(
async () => {
const res = await api.get(MEMORY_ENDPOINTS.CONFIGURE);
return getEffectiveConfig(res.data);
},
{ errorToast: "Failed to load server configuration" },
);
const { data: providers } = useApiQuery<BundledProviders>(
async () => {
const res = await api.get<BundledProviders>(
MEMORY_ENDPOINTS.CONFIGURE_PROVIDERS,
);
return res.data;
},
{ errorToast: "Failed to load bundled providers" },
);
useEffect(() => {
if (!config) return;
setLlmProvider((current) => current || config.llm?.provider || "");
setLlmModel((current) => current || config.llm?.config?.model || "");
setEmbedderProvider(
(current) => current || config.embedder?.provider || "",
);
setEmbedderModel(
(current) => current || config.embedder?.config?.model || "",
);
}, [config]);
const handleSave = async () => {
setIsSaving(true);
try {
const llm = buildProviderConfig({
provider: llmProvider,
model: llmModel,
apiKey: llmApiKey,
});
const embedder = buildProviderConfig({
provider: embedderProvider,
model: embedderModel,
});
const newConfig: Record<string, unknown> = {
version: "v1.1",
};
if (llm) {
newConfig.llm = llm;
}
if (embedder) {
newConfig.embedder = embedder;
}
await api.post(MEMORY_ENDPOINTS.CONFIGURE, newConfig);
toast({ title: "Configuration saved", variant: "success" });
} catch (error) {
toast({
title: "Failed to save configuration",
description: getErrorMessage(error),
variant: "destructive",
});
} finally {
setIsSaving(false);
}
};
return (
<div className="space-y-6">
<div className="space-y-1">
<h1 className="text-xl font-semibold font-fustat">Configuration</h1>
{isPrefilling && (
<p className="text-sm text-onSurface-default-tertiary">
Loading effective server configuration...
</p>
)}
</div>
<Card className="border-memBorder-primary">
<CardHeader>
<CardTitle className="text-sm">LLM Provider</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Provider</Label>
<Select
value={llmProvider}
onValueChange={setLlmProvider}
disabled={!isAdmin || !providers}
>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{providers?.llm.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Model</Label>
<Input
placeholder="gpt-4.1-nano-2025-04-14"
value={llmModel}
onChange={(e) => setLlmModel(e.target.value)}
disabled={!isAdmin}
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">API Key</Label>
<Input
type="password"
placeholder="sk-..."
value={llmApiKey}
onChange={(e) => setLlmApiKey(e.target.value)}
disabled={!isAdmin}
/>
</div>
</CardContent>
</Card>
<Card className="border-memBorder-primary">
<CardHeader>
<CardTitle className="text-sm">Embedding Model</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Provider</Label>
<Select
value={embedderProvider}
onValueChange={setEmbedderProvider}
disabled={!isAdmin || !providers}
>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{providers?.embedder.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Model</Label>
<Input
placeholder="text-embedding-3-small"
value={embedderModel}
onChange={(e) => setEmbedderModel(e.target.value)}
disabled={!isAdmin}
/>
</div>
</div>
</CardContent>
</Card>
<p className="text-xs text-onSurface-default-tertiary">
Need another provider? Install its Python package, rebuild the image,
and extend the bundled list. See the{" "}
<a
href="https://docs.mem0.ai/open-source/setup#supported-providers"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-4 hover:text-onSurface-default-primary"
>
setup guide
</a>
.
</p>
{isAdmin && (
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save Configuration"}
</Button>
)}
<UpgradeBanner
id="config-sso"
message="Looking for SSO / SAML? Available in Enterprise."
ctaLabel="Contact sales"
ctaUrl="https://app.mem0.ai/enterprise?utm_source=oss&utm_medium=dashboard-configuration-sso"
variant="enterprise"
/>
</div>
);
}
@@ -0,0 +1,134 @@
"use client";
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { format } from "date-fns";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { DataTable } from "@/components/shared/data-table";
import { TableSkeleton } from "@/components/shared/table-skeleton";
import { EmptyState } from "@/components/self-hosted/empty-state";
import DeleteConfirmationModal from "@/components/ui/delete-confirmation-modal";
import { toast } from "@/components/ui/use-toast";
import { api } from "@/utils/api";
import { ENTITY_ENDPOINTS } from "@/utils/api-endpoints";
import { getErrorMessage } from "@/lib/error-message";
import { useApiQuery } from "@/hooks/use-api-query";
import { Entity } from "@/types/api";
export default function EntitiesPage() {
const [entityToDelete, setEntityToDelete] = useState<Entity | null>(null);
const {
data: entities = [],
isLoading,
refetch,
} = useApiQuery<Entity[]>(
async () => {
const res = await api.get<Entity[]>(ENTITY_ENDPOINTS.BASE);
return res.data ?? [];
},
{ errorToast: "Failed to load entities", initialData: [] },
);
const handleDelete = async () => {
if (!entityToDelete) return;
try {
await api.delete(
ENTITY_ENDPOINTS.BY_ID(entityToDelete.type, entityToDelete.id),
);
toast({ title: "Entity deleted", variant: "success" });
setEntityToDelete(null);
void refetch();
} catch (error) {
toast({
title: "Failed to delete entity",
description: getErrorMessage(error),
variant: "destructive",
});
}
};
const columns = [
{
key: "type" as keyof Entity,
label: "Type",
width: 100,
render: (value: Entity["type"]) => (
<Badge variant="outline" className="capitalize">
{value}
</Badge>
),
},
{
key: "id" as keyof Entity,
label: "ID",
width: 280,
render: (value: string) => (
<span className="font-mono text-sm truncate">{value}</span>
),
},
{
key: "total_memories" as keyof Entity,
label: "Memories",
width: 100,
align: "right" as const,
},
{
key: "updated_at" as keyof Entity,
label: "Last Active",
width: 140,
render: (value: string | null) =>
value ? format(new Date(value), "MMM d, yyyy") : "--",
},
{
key: "id" as keyof Entity,
label: "",
width: 40,
render: (_: string, row: Entity) => (
<Button
variant="ghost"
size="icon"
onClick={() => setEntityToDelete(row)}
className="size-7"
>
<Trash2 className="size-3.5 text-onSurface-danger-primary" />
</Button>
),
},
];
return (
<div className="space-y-4">
<h1 className="text-xl font-semibold font-fustat">Entities</h1>
{isLoading ? (
<TableSkeleton rows={5} columns={5} />
) : entities.length === 0 ? (
<EmptyState
title="No entities yet"
description="Entities appear once memories are stored with a user_id, agent_id, or run_id."
/>
) : (
<Card className="border-memBorder-primary overflow-hidden">
<DataTable
data={entities}
columns={columns}
getRowKey={(row) => `${row.type}:${row.id}`}
/>
</Card>
)}
<DeleteConfirmationModal
isOpen={!!entityToDelete}
onClose={() => setEntityToDelete(null)}
onConfirm={handleDelete}
title="Delete entity"
description="All memories associated with this entity will be permanently removed. This cannot be undone."
itemName={entityToDelete?.id ?? ""}
confirmButtonText="Delete"
/>
</div>
);
}
@@ -0,0 +1,65 @@
"use client";
import { LockedPage } from "@/components/self-hosted/locked-page";
import { Card, CardContent } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
function ExportMockup() {
return (
<div className="space-y-4">
<Card className="border-memBorder-primary">
<CardContent className="p-6 space-y-4">
<div className="space-y-2">
<Label>Format</Label>
<div className="flex gap-2">
{["JSON", "CSV", "Pydantic Schema"].map((fmt) => (
<Button
key={fmt}
variant="outline"
size="sm"
disabled
className={fmt === "JSON" ? "border-memPurple-300" : ""}
>
{fmt}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label>Sample output</Label>
<Card className="border-memBorder-primary bg-surface-default-secondary">
<CardContent className="p-3">
<pre className="text-xs text-onSurface-default-secondary font-mono whitespace-pre-wrap">
{`{
"memories": [
{
"id": "mem_abc123",
"content": "User prefers dark mode",
"user_id": "user_1",
"created_at": "2026-04-10T12:00:00Z"
}
],
"total": 1247
}`}
</pre>
</CardContent>
</Card>
</div>
<Button disabled>Export Memories</Button>
</CardContent>
</Card>
</div>
);
}
export default function ExportPage() {
return (
<LockedPage
title="Export"
description="Export your memories in JSON, CSV, or Pydantic schema format."
previewContent={<ExportMockup />}
utmMedium="dashboard-locked-export"
/>
);
}
@@ -0,0 +1,37 @@
"use client";
import NavWrapper from "./components/nav-wrapper";
import { cn } from "@/lib/utils";
import { SIDEBAR_WIDTH, COLLAPSED_SIDEBAR_WIDTH } from "../clientLayout";
import { useSelector } from "react-redux";
import { RootState } from "@/store/store";
import { ScrollArea } from "@/components/ui/scroll-area";
export default function DashboardLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const isSidebarCollapsed = useSelector(
(state: RootState) => state.layout.isSidebarCollapsed,
);
return (
<>
<NavWrapper />
<div
className="mt-[48px] rounded-tl-lg relative h-[calc(100vh-56px)] bg-surface-default-primary border border-memBorder-primary overflow-hidden transition-all duration-300 ease-in-out font-fustat"
style={{
left: `${isSidebarCollapsed ? COLLAPSED_SIDEBAR_WIDTH : SIDEBAR_WIDTH}px`,
width: `calc(100vw - ${isSidebarCollapsed ? COLLAPSED_SIDEBAR_WIDTH + 8 : SIDEBAR_WIDTH + 8}px)`,
}}
>
<ScrollArea type="scroll" className="h-[calc(100vh-70px)]">
<div className="mx-auto px-6 py-6 flex-1 flex-col space-y-4">
{children}
</div>
</ScrollArea>
</div>
</>
);
}
@@ -0,0 +1,273 @@
"use client";
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { format } from "date-fns";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card } from "@/components/ui/card";
import { DataTable } from "@/components/shared/data-table";
import { TableSkeleton } from "@/components/shared/table-skeleton";
import { EmptyState } from "@/components/self-hosted/empty-state";
import DeleteConfirmationModal from "@/components/ui/delete-confirmation-modal";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet";
import { UpgradeBanner } from "@/components/self-hosted/upgrade-banner";
import { toast } from "@/components/ui/use-toast";
import { getErrorMessage } from "@/lib/error-message";
import { api } from "@/utils/api";
import { MEMORY_ENDPOINTS } from "@/utils/api-endpoints";
import { useApiQuery } from "@/hooks/use-api-query";
import { Memory } from "@/types/api";
const PAGE_SIZE = 20;
export default function MemoriesPage() {
const [userId, setUserId] = useState("");
const [selectedMemory, setSelectedMemory] = useState<Memory | null>(null);
const [memoryToDelete, setMemoryToDelete] = useState<Memory | null>(null);
const [page, setPage] = useState(0);
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const {
data: memories = [],
isLoading,
refetch,
} = useApiQuery<Memory[]>(
async () => {
const params = userId.trim() ? { user_id: userId.trim() } : undefined;
const res = await api.get(MEMORY_ENDPOINTS.BASE, { params });
const raw = res.data?.results ?? res.data ?? [];
return Array.isArray(raw) ? raw : [];
},
{ errorToast: "Failed to load memories", initialData: [] },
);
const totalPages = Math.ceil(memories.length / PAGE_SIZE);
const paginatedMemories = memories.slice(
page * PAGE_SIZE,
(page + 1) * PAGE_SIZE,
);
const handleDelete = async () => {
if (!memoryToDelete) return;
try {
await api.delete(MEMORY_ENDPOINTS.BY_ID(memoryToDelete.id));
toast({ title: "Memory deleted", variant: "success" });
if (selectedMemory?.id === memoryToDelete.id) setSelectedMemory(null);
setMemoryToDelete(null);
void refetch();
} catch (error) {
toast({
title: "Failed to delete memory",
description: getErrorMessage(error),
variant: "destructive",
});
}
};
const columns = [
{
key: "memory" as keyof Memory,
label: "Content",
width: 400,
render: (value: string) => (
<span className="line-clamp-2 text-sm">{value}</span>
),
},
{ key: "user_id" as keyof Memory, label: "User", width: 100 },
{ key: "agent_id" as keyof Memory, label: "Agent", width: 100 },
{
key: "created_at" as keyof Memory,
label: "Created",
width: 120,
render: (value: string) =>
value ? format(new Date(value), "MMM d, yyyy") : "--",
},
];
return (
<div className="space-y-4">
<h1 className="text-xl font-semibold font-fustat">Memories</h1>
{memories.length >= 1000 && (
<UpgradeBanner
id="memories-1k"
message="1,000+ memories stored. Categories can help organize them."
ctaLabel="Explore Cloud"
ctaUrl="https://app.mem0.ai?utm_source=oss&utm_medium=dashboard-memories"
variant="cloud"
/>
)}
<div className="flex gap-3">
<Input
placeholder="Filter by User ID (optional)"
value={userId}
onChange={(e) => setUserId(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
setPage(0);
refetch();
}
}}
className="w-64"
/>
</div>
{isLoading ? (
<TableSkeleton rows={5} columns={4} />
) : memories.length === 0 ? (
<EmptyState
title="No memories yet"
description="Create your first memory by sending a POST /memories request."
>
<pre className="text-xs text-left bg-surface-default-secondary p-3 rounded font-mono overflow-x-auto mt-3 max-w-lg">
{`curl -X POST ${apiUrl}/memories \\
-H "X-API-Key: <your-key>" \\
-H "Content-Type: application/json" \\
-d '{"messages": [{"role": "user", "content": "I like hiking"}], "user_id": "alice"}'`}
</pre>
<a
href="https://docs.mem0.ai/open-source/features/rest-api#memory-operations"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-onSurface-default-tertiary underline underline-offset-4 hover:text-onSurface-default-primary mt-2"
>
REST API reference
</a>
</EmptyState>
) : (
<>
<Card className="border-memBorder-primary overflow-hidden">
<DataTable
data={paginatedMemories}
columns={columns}
getRowKey={(row) => row.id}
onRowClick={(row) => setSelectedMemory(row)}
getRowClassName={(row) =>
selectedMemory?.id === row.id
? "bg-surface-default-tertiary"
: undefined
}
/>
</Card>
{totalPages > 1 && (
<div className="flex items-center justify-between text-sm text-onSurface-default-tertiary">
<span>
{page * PAGE_SIZE + 1}–
{Math.min((page + 1) * PAGE_SIZE, memories.length)} of{" "}
{memories.length}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
)}
</>
)}
<Sheet
open={!!selectedMemory}
onOpenChange={(open) => {
if (!open) setSelectedMemory(null);
}}
>
<SheetContent className="sm:max-w-md">
<SheetHeader>
<SheetTitle>Memory Detail</SheetTitle>
<SheetDescription className="sr-only">
View memory content and metadata
</SheetDescription>
</SheetHeader>
{selectedMemory && (
<div className="mt-6 space-y-4">
<div className="space-y-1">
<Label className="text-xs text-onSurface-default-tertiary">
Content
</Label>
<p className="text-sm">{selectedMemory.memory}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label className="text-xs text-onSurface-default-tertiary">
ID
</Label>
<p className="text-xs font-mono break-all">
{selectedMemory.id}
</p>
</div>
{selectedMemory.user_id && (
<div className="space-y-1">
<Label className="text-xs text-onSurface-default-tertiary">
User
</Label>
<p className="text-sm">{selectedMemory.user_id}</p>
</div>
)}
{selectedMemory.agent_id && (
<div className="space-y-1">
<Label className="text-xs text-onSurface-default-tertiary">
Agent
</Label>
<p className="text-sm">{selectedMemory.agent_id}</p>
</div>
)}
{selectedMemory.created_at && (
<div className="space-y-1">
<Label className="text-xs text-onSurface-default-tertiary">
Created
</Label>
<p className="text-sm">
{new Date(selectedMemory.created_at).toLocaleString()}
</p>
</div>
)}
</div>
<Button
variant="outline"
size="sm"
className="text-onSurface-danger-primary"
onClick={() => setMemoryToDelete(selectedMemory)}
>
<Trash2 className="size-3.5 mr-1" />
Delete memory
</Button>
</div>
)}
</SheetContent>
</Sheet>
<DeleteConfirmationModal
isOpen={!!memoryToDelete}
onClose={() => setMemoryToDelete(null)}
onConfirm={handleDelete}
title="Delete memory"
description="This memory will be permanently removed. This cannot be undone."
itemName={memoryToDelete?.id ?? ""}
confirmButtonText="Delete"
/>
</div>
);
}
@@ -0,0 +1,277 @@
"use client";
import { useState } from "react";
import { format, formatDistanceToNow } from "date-fns";
import { RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { DataTable } from "@/components/shared/data-table";
import { TableSkeleton } from "@/components/shared/table-skeleton";
import { EmptyState } from "@/components/self-hosted/empty-state";
import { api } from "@/utils/api";
import { REQUEST_ENDPOINTS } from "@/utils/api-endpoints";
import { useApiQuery } from "@/hooks/use-api-query";
import { ApiRequestLog } from "@/types/api";
type RequestLog = {
id: string;
createdAt: string;
method: string;
path: string;
statusCode: number;
latencyMs: number;
authType: string;
};
const REQUEST_LOG_LIMIT = 200;
const PAGE_SIZE = 20;
const getStatusClassName = (statusCode: number) => {
if (statusCode >= 500) {
return "border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/40 dark:bg-rose-950/40 dark:text-rose-300";
}
if (statusCode >= 400) {
return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/40 dark:bg-amber-950/40 dark:text-amber-300";
}
return "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-950/40 dark:text-emerald-300";
};
const getMethodClassName = (method: string) => {
switch (method.toUpperCase()) {
case "POST":
return "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/40 dark:bg-sky-950/40 dark:text-sky-300";
case "PUT":
case "PATCH":
return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/40 dark:bg-amber-950/40 dark:text-amber-300";
case "DELETE":
return "border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/40 dark:bg-rose-950/40 dark:text-rose-300";
default:
return "border-memBorder-primary bg-surface-default-secondary text-onSurface-default-secondary";
}
};
const getAuthLabel = (authType: string) => {
switch (authType.toLowerCase()) {
case "bearer":
return "JWT";
case "api_key":
return "API Key";
case "admin_api_key":
return "Admin Key";
case "disabled":
return "Disabled";
default:
return "--";
}
};
const normalizeLog = (entry: ApiRequestLog): RequestLog => {
return {
id: entry.id,
createdAt: entry.created_at,
method: entry.method,
path: entry.path,
statusCode: entry.status_code,
latencyMs: entry.latency_ms,
authType: entry.auth_type,
};
};
export default function RequestsPage() {
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
const [page, setPage] = useState(0);
const {
data: logs = [],
isLoading,
error,
refetch,
} = useApiQuery<RequestLog[]>(
async () => {
const res = await api.get<ApiRequestLog[]>(REQUEST_ENDPOINTS.BASE, {
params: { limit: REQUEST_LOG_LIMIT },
});
setLastUpdated(new Date().toISOString());
return (res.data ?? []).map(normalizeLog);
},
{ errorToast: "Failed to load request logs", initialData: [] },
);
const totalRequests = logs.length;
const successfulRequests = logs.filter((log) => log.statusCode < 400).length;
const successRate =
totalRequests > 0
? Math.round((successfulRequests / totalRequests) * 100)
: 0;
const averageLatency =
totalRequests > 0
? Math.round(
logs.reduce((sum, log) => sum + log.latencyMs, 0) / totalRequests,
)
: 0;
const columns = [
{
key: "createdAt" as keyof RequestLog,
label: "Time",
width: 140,
render: (value: string) => (
<span title={format(new Date(value), "PPpp")}>
{formatDistanceToNow(new Date(value), { addSuffix: true })}
</span>
),
},
{
key: "method" as keyof RequestLog,
label: "Method",
width: 96,
render: (value: string) => (
<Badge variant="outline" className={getMethodClassName(value)}>
{value.toUpperCase()}
</Badge>
),
},
{
key: "path" as keyof RequestLog,
label: "Path",
width: 360,
render: (value: string) => (
<span className="font-mono text-xs break-all text-onSurface-default-primary">
{value}
</span>
),
},
{
key: "statusCode" as keyof RequestLog,
label: "Status",
width: 120,
render: (value: number) => (
<Badge variant="outline" className={getStatusClassName(value)}>
{value}
</Badge>
),
},
{
key: "latencyMs" as keyof RequestLog,
label: "Latency",
width: 100,
render: (value: number) => <span>{value} ms</span>,
},
{
key: "authType" as keyof RequestLog,
label: "Auth",
width: 120,
render: (value: string) => getAuthLabel(value),
},
];
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<h1 className="text-xl font-semibold font-fustat">Requests</h1>
<p className="text-sm text-onSurface-default-secondary">
Recent request logs from your self-hosted instance.
</p>
{lastUpdated && (
<p className="text-xs text-onSurface-default-tertiary">
Last updated{" "}
{formatDistanceToNow(new Date(lastUpdated), { addSuffix: true })}
</p>
)}
</div>
<Button
variant="outline"
onClick={() => {
setPage(0);
void refetch();
}}
disabled={isLoading}
>
<RefreshCw className="size-4 mr-2" />
Refresh
</Button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{[
{ label: "Total Requests", value: totalRequests },
{
label: "Success Rate",
value: totalRequests > 0 ? `${successRate}%` : "--",
},
{
label: "Avg Latency",
value: totalRequests > 0 ? `${averageLatency} ms` : "--",
},
].map((card) => (
<Card key={card.label} className="border-memBorder-primary">
<CardContent className="p-5">
<p className="text-xs text-onSurface-default-tertiary">
{card.label}
</p>
<p className="mt-1 text-2xl font-semibold">{card.value}</p>
</CardContent>
</Card>
))}
</div>
{error && (
<Card className="border-memBorder-primary">
<CardContent className="p-4 text-sm text-onSurface-danger-primary">
{error}
</CardContent>
</Card>
)}
{isLoading ? (
<TableSkeleton rows={6} columns={6} />
) : logs.length === 0 ? (
<EmptyState
title="No request logs yet"
description="Requests will appear here once your instance receives traffic."
image="requests"
/>
) : (
<>
<Card className="border-memBorder-primary overflow-hidden">
<DataTable
data={logs.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)}
columns={columns}
getRowKey={(row) => row.id}
/>
</Card>
{logs.length > PAGE_SIZE && (
<div className="flex items-center justify-between text-sm text-onSurface-default-tertiary">
<span>
{page * PAGE_SIZE + 1}–
{Math.min((page + 1) * PAGE_SIZE, logs.length)} of {logs.length}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={(page + 1) * PAGE_SIZE >= logs.length}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,218 @@
"use client";
import { useEffect, useState } from "react";
import { Sun, Moon, Monitor } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "@/components/ui/use-toast";
import { useAuth } from "@/hooks/use-auth";
import { getErrorMessage } from "@/lib/error-message";
import { api } from "@/utils/api";
import { AUTH_ENDPOINTS } from "@/utils/api-endpoints";
export default function SettingsPage() {
const { user, refreshUser } = useAuth();
const { setTheme } = useTheme();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [savingProfile, setSavingProfile] = useState(false);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [savingPassword, setSavingPassword] = useState(false);
useEffect(() => {
if (user) {
setName(user.name);
setEmail(user.email);
}
}, [user]);
const profileDirty =
user !== null && (name !== user.name || email !== user.email);
const profileValid = name.trim().length > 0 && email.trim().length > 0;
const handleSaveProfile = async () => {
setSavingProfile(true);
try {
await api.patch(AUTH_ENDPOINTS.ME, {
name: name.trim(),
email: email.trim(),
});
await refreshUser();
toast({ title: "Profile updated", variant: "success" });
} catch (error) {
toast({
title: "Failed to update profile",
description: getErrorMessage(error),
variant: "destructive",
});
} finally {
setSavingProfile(false);
}
};
const handleChangePassword = async () => {
if (newPassword !== confirmPassword) {
toast({
title: "Passwords don't match",
variant: "destructive",
});
return;
}
setSavingPassword(true);
try {
await api.post(AUTH_ENDPOINTS.CHANGE_PASSWORD, {
current_password: currentPassword,
new_password: newPassword,
});
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
toast({ title: "Password updated", variant: "success" });
} catch (error) {
toast({
title: "Failed to update password",
description: getErrorMessage(error),
variant: "destructive",
});
} finally {
setSavingPassword(false);
}
};
return (
<div className="space-y-6">
<h1 className="text-xl font-semibold font-fustat">Settings</h1>
<Card className="border-memBorder-primary">
<CardHeader>
<CardTitle className="text-sm">Profile</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="settings-name" className="text-xs">
Name
</Label>
<Input
id="settings-name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="settings-email" className="text-xs">
Email
</Label>
<Input
id="settings-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
</div>
<Button
onClick={handleSaveProfile}
disabled={!profileDirty || !profileValid || savingProfile}
>
{savingProfile ? "Saving..." : "Save profile"}
</Button>
</CardContent>
</Card>
<Card className="border-memBorder-primary">
<CardHeader>
<CardTitle className="text-sm">Password</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<Label htmlFor="settings-current-password" className="text-xs">
Current password
</Label>
<Input
id="settings-current-password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="settings-new-password" className="text-xs">
New password
</Label>
<Input
id="settings-new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Min 8 characters"
/>
</div>
<div className="space-y-1">
<Label htmlFor="settings-confirm-password" className="text-xs">
Confirm new password
</Label>
<Input
id="settings-confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
</div>
<Button
onClick={handleChangePassword}
disabled={
!currentPassword ||
newPassword.length < 8 ||
!confirmPassword ||
savingPassword
}
>
{savingPassword ? "Saving..." : "Update password"}
</Button>
</CardContent>
</Card>
<Card className="border-memBorder-primary">
<CardHeader>
<CardTitle className="text-sm">Appearance</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<span className="text-sm text-onSurface-default-secondary">
Theme
</span>
<button
onClick={() => setTheme("light")}
className="p-2 rounded hover:bg-surface-default-secondary-hover"
>
<Sun className="size-4" />
</button>
<button
onClick={() => setTheme("dark")}
className="p-2 rounded hover:bg-surface-default-secondary-hover"
>
<Moon className="size-4" />
</button>
<button
onClick={() => setTheme("system")}
className="p-2 rounded hover:bg-surface-default-secondary-hover"
>
<Monitor className="size-4" />
</button>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,53 @@
"use client";
import { LockedPage } from "@/components/self-hosted/locked-page";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
function WebhooksMockup() {
return (
<Card className="border-memBorder-primary">
<CardContent className="p-6 space-y-4">
<div className="space-y-2">
<Label>Endpoint URL</Label>
<Input placeholder="https://your-app.com/webhooks/mem0" disabled />
</div>
<div className="space-y-2">
<Label>Events</Label>
<div className="flex flex-col gap-2">
{[
"memory.created",
"memory.updated",
"memory.deleted",
"search.performed",
].map((event) => (
<div key={event} className="flex items-center gap-2">
<Checkbox disabled checked={event === "memory.created"} />
<Label className="text-sm font-normal">{event}</Label>
</div>
))}
</div>
</div>
<div className="space-y-2">
<Label>Signing Secret</Label>
<Input placeholder="whsec_..." disabled type="password" />
</div>
<Button disabled>Create Webhook</Button>
</CardContent>
</Card>
);
}
export default function WebhooksPage() {
return (
<LockedPage
title="Webhooks"
description="Get notified when memories are created, updated, or deleted."
previewContent={<WebhooksMockup />}
utmMedium="dashboard-locked-webhooks"
/>
);
}
+86
View File
@@ -0,0 +1,86 @@
import localFont from "next/font/local";
export const Inter = localFont({
src: [
{
path: "../../../public/fonts/InterVariable.woff2",
weight: "100 900",
style: "normal",
},
{
path: "../../../public/fonts/InterVariable-Italic.woff2",
weight: "100 900",
style: "italic",
},
],
variable: "--font-inter",
display: "swap",
});
export const InterDisplay = Inter;
export const Roboto = localFont({
src: [
{
path: "../../../public/fonts/RobotoMono-Regular.ttf",
weight: "400",
style: "normal",
},
{
path: "../../../public/fonts/RobotoMono-Bold.ttf",
weight: "700",
style: "normal",
},
],
variable: "--font-roboto-mono",
display: "swap",
});
export const Fustat = localFont({
src: [
{
path: "../../../public/fonts/Fustat-VariableFont_wght.ttf",
weight: "200 800",
style: "normal",
},
],
variable: "--font-fustat",
display: "swap",
});
export const DMMono = localFont({
src: [
{
path: "../../../public/fonts/DMMono-Light.ttf",
weight: "300",
style: "normal",
},
{
path: "../../../public/fonts/DMMono-LightItalic.ttf",
weight: "300",
style: "italic",
},
{
path: "../../../public/fonts/DMMono-Regular.ttf",
weight: "400",
style: "normal",
},
{
path: "../../../public/fonts/DMMono-Italic.ttf",
weight: "400",
style: "italic",
},
{
path: "../../../public/fonts/DMMono-Medium.ttf",
weight: "500",
style: "normal",
},
{
path: "../../../public/fonts/DMMono-MediumItalic.ttf",
weight: "500",
style: "italic",
},
],
variable: "--font-dm-mono",
display: "swap",
});
@@ -0,0 +1,15 @@
import { Metadata } from "next";
import { DashboardClientLayout } from "./dashboard-client-layout";
export const metadata: Metadata = {
title: "Dashboard | Mem0",
description: "Mem0 Dashboard",
};
export default function DashboardLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return <DashboardClientLayout>{children}</DashboardClientLayout>;
}
@@ -0,0 +1,61 @@
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { AUTH_ENDPOINTS } from "@/utils/api-endpoints";
import { getServerApiUrl } from "@/lib/server-api-url";
const COOKIE_NAME = "mem0_refresh_token";
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
maxAge: 30 * 24 * 60 * 60, // 30 days
};
export async function POST() {
const cookieStore = await cookies();
const refreshToken = cookieStore.get(COOKIE_NAME)?.value;
if (!refreshToken) {
return NextResponse.json({ error: "No refresh token" }, { status: 401 });
}
const res = await fetch(`${getServerApiUrl()}${AUTH_ENDPOINTS.REFRESH}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!res.ok) {
// Refresh token is invalid — clear cookie
cookieStore.delete(COOKIE_NAME);
return NextResponse.json({ error: "Refresh failed" }, { status: 401 });
}
const data = await res.json();
cookieStore.set(COOKIE_NAME, data.refresh_token, COOKIE_OPTIONS);
return NextResponse.json({ access_token: data.access_token });
}
export async function PUT(request: NextRequest) {
const body = await request.json();
const cookieStore = await cookies();
if (!body.refresh_token) {
return NextResponse.json(
{ error: "Missing refresh_token" },
{ status: 400 },
);
}
cookieStore.set(COOKIE_NAME, body.refresh_token, COOKIE_OPTIONS);
return NextResponse.json({ ok: true });
}
export async function DELETE() {
const cookieStore = await cookies();
cookieStore.delete(COOKIE_NAME);
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,6 @@
export const GET = async () => {
return new Response(JSON.stringify({ status: "ok" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+42
View File
@@ -0,0 +1,42 @@
import "@/styles/globals.css";
import { Inter, Fustat, Roboto, DMMono, InterDisplay } from "../(root)/fonts";
import { cn } from "@/lib/utils";
import { ThemeProvider } from "@/components/theme-provider";
import { AuthProvider } from "@/lib/auth";
export const metadata = {
title: "Setup | Mem0",
description: "Set up your Mem0 instance",
};
export default function SetupLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="en"
className={cn(
Fustat.variable,
InterDisplay.variable,
Inter.variable,
Roboto.variable,
DMMono.variable,
)}
suppressHydrationWarning
>
<body className="font-fustat" suppressHydrationWarning>
<AuthProvider>
<ThemeProvider
attribute="class"
defaultTheme="light"
disableTransitionOnChange
>
{children}
</ThemeProvider>
</AuthProvider>
</body>
</html>
);
}
+760
View File
@@ -0,0 +1,760 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Check, Copy } from "lucide-react";
import { CopyToClipboard } from "react-copy-to-clipboard";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useAuth } from "@/hooks/use-auth";
import { getErrorMessage } from "@/lib/error-message";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import {
API_KEY_ENDPOINTS,
AUTH_ENDPOINTS,
MEMORY_ENDPOINTS,
} from "@/utils/api-endpoints";
import {
buildProviderConfig,
getEffectiveConfig,
} from "@/utils/self-hosted-config";
import { isValidEmail } from "@/lib/validators";
type BundledProviders = {
llm: string[];
embedder: string[];
};
const STEPS = [
"Admin Account",
"Providers",
"API Key",
"Use Case",
"Quick Test",
];
const STEP_TITLES = [
"Create your admin account",
"Review provider configuration",
"Your API key",
"Tell us your use case",
"Test your setup",
];
const SUPPORTED_PROVIDERS_URL =
"https://docs.mem0.ai/open-source/setup#supported-providers";
const USE_CASE_PRESETS = [
"Personal assistant",
"Coding agent",
"Customer support",
"Research",
"Therapy / journaling",
];
const DEFAULT_TEST_MESSAGE = "I like to hike on weekends.";
export default function SetupPage() {
const router = useRouter();
const { register } = useAuth();
const [step, setStep] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [isPrefillingConfig, setIsPrefillingConfig] = useState(false);
const [error, setError] = useState("");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [llmProvider, setLlmProvider] = useState("");
const [llmModel, setLlmModel] = useState("");
const [llmApiKey, setLlmApiKey] = useState("");
const [embedderProvider, setEmbedderProvider] = useState("");
const [embedderModel, setEmbedderModel] = useState("");
const [serverHasLlmKey, setServerHasLlmKey] = useState(false);
const [providers, setProviders] = useState<BundledProviders | null>(null);
const [initialLlmProvider, setInitialLlmProvider] = useState("");
const [initialLlmModel, setInitialLlmModel] = useState("");
const [initialEmbedderProvider, setInitialEmbedderProvider] = useState("");
const [initialEmbedderModel, setInitialEmbedderModel] = useState("");
const [apiKey, setApiKey] = useState("");
const [keyLabel, setKeyLabel] = useState("");
const [copied, setCopied] = useState(false);
const [testSuccess, setTestSuccess] = useState(false);
const [useCase, setUseCase] = useState("");
const [customInstructions, setCustomInstructions] = useState("");
const [testMessage, setTestMessage] = useState(DEFAULT_TEST_MESSAGE);
const [isGeneratingInstructions, setIsGeneratingInstructions] =
useState(false);
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
useEffect(() => {
if (step !== 1) {
setIsPrefillingConfig(false);
return;
}
let active = true;
setIsPrefillingConfig(true);
const loadConfig = async () => {
try {
const [configRes, providersRes] = await Promise.all([
api.get(MEMORY_ENDPOINTS.CONFIGURE),
api.get<BundledProviders>(MEMORY_ENDPOINTS.CONFIGURE_PROVIDERS),
]);
const config = getEffectiveConfig(configRes.data);
if (!active) {
return;
}
const llmProv = config?.llm?.provider || "";
const llmMod = config?.llm?.config?.model || "";
const embProv = config?.embedder?.provider || "";
const embMod = config?.embedder?.config?.model || "";
setLlmProvider(llmProv);
setLlmModel(llmMod);
setEmbedderProvider(embProv);
setEmbedderModel(embMod);
setInitialLlmProvider(llmProv);
setInitialLlmModel(llmMod);
setInitialEmbedderProvider(embProv);
setInitialEmbedderModel(embMod);
setServerHasLlmKey(!!config?.llm?.config?.api_key);
setProviders(providersRes.data);
} catch (err) {
if (active) {
setError(getErrorMessage(err, "Could not read server configuration"));
}
} finally {
if (active) {
setIsPrefillingConfig(false);
}
}
};
void loadConfig();
return () => {
active = false;
};
}, [step]);
const handleStep1 = async (e: React.FormEvent) => {
e.preventDefault();
if (!isValidEmail(email)) {
setError("Enter a valid email address.");
return;
}
if (password !== confirmPassword) {
setError("Passwords don't match");
return;
}
if (password.length < 8) {
setError("Password must be at least 8 characters");
return;
}
setError("");
setIsLoading(true);
try {
await register(name, email, password);
setStep(1);
} catch (err) {
setError(getErrorMessage(err, "Registration failed"));
} finally {
setIsLoading(false);
}
};
const handleStep2 = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
const dirty =
!!llmApiKey ||
llmProvider !== initialLlmProvider ||
llmModel !== initialLlmModel ||
embedderProvider !== initialEmbedderProvider ||
embedderModel !== initialEmbedderModel;
if (!dirty) {
setStep(2);
return;
}
setIsLoading(true);
try {
const llm = buildProviderConfig({
provider: llmProvider,
model: llmModel,
apiKey: llmApiKey,
});
const embedder = buildProviderConfig({
provider: embedderProvider,
model: embedderModel,
apiKey:
llmApiKey && embedderProvider === llmProvider ? llmApiKey : undefined,
});
const payload: Record<string, unknown> = { version: "v1.1" };
if (llm) payload.llm = llm;
if (embedder) payload.embedder = embedder;
await api.post(MEMORY_ENDPOINTS.CONFIGURE, payload);
if (llmApiKey) setServerHasLlmKey(true);
setLlmApiKey("");
setInitialLlmProvider(llmProvider);
setInitialLlmModel(llmModel);
setInitialEmbedderProvider(embedderProvider);
setInitialEmbedderModel(embedderModel);
setStep(2);
} catch (err) {
setError(getErrorMessage(err, "Failed to save configuration"));
} finally {
setIsLoading(false);
}
};
const handleStep3 = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setIsLoading(true);
try {
const res = await api.post(API_KEY_ENDPOINTS.BASE, {
label: keyLabel.trim() || "My First Key",
});
setApiKey(res.data.key);
} catch (err) {
setError(getErrorMessage(err, "Failed to create API key"));
} finally {
setIsLoading(false);
}
};
const handleContinueToUseCase = (e: React.FormEvent) => {
e.preventDefault();
setStep(3);
};
const handleContinueToQuickTest = () => {
setError("");
setStep(4);
};
const handleGoToDashboard = (e: React.FormEvent) => {
e.preventDefault();
router.push("/dashboard/requests");
};
const handleTest = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setIsLoading(true);
try {
const res = await fetch(`${apiUrl}/memories`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-API-Key": apiKey },
body: JSON.stringify({
messages: [{ role: "user", content: testMessage }],
user_id: "setup-test",
}),
});
if (!res.ok) {
const body = await res.text();
let detail = body;
try {
detail = JSON.parse(body).detail ?? body;
} catch {}
throw new Error(
`Test failed (${res.status}): ${detail || res.statusText}`,
);
}
setTestSuccess(true);
void api
.post(AUTH_ENDPOINTS.ONBOARDING_COMPLETE, { use_case: useCase })
.catch(() => {});
} catch (err) {
setError(getErrorMessage(err, "Test failed"));
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-surface-default-primary p-4">
<div className="w-full max-w-[560px] space-y-6">
<div className="flex items-center justify-center gap-2">
{STEPS.map((label, i) => (
<div key={label} className="flex items-center gap-2">
<div
className={cn(
"size-7 rounded-full flex items-center justify-center text-xs font-medium",
i <= step
? "bg-memPurple-500 text-white"
: "bg-memNeutral-200 text-onSurface-default-tertiary",
)}
>
{i < step ? <Check className="size-3.5" /> : i + 1}
</div>
{i < STEPS.length - 1 && (
<div
className={cn(
"w-8 h-[2px]",
i < step ? "bg-memPurple-500" : "bg-memNeutral-200",
)}
/>
)}
</div>
))}
</div>
<p className="text-center text-sm text-onSurface-default-tertiary">
{STEPS[step]}
</p>
<Card className="border-memBorder-primary">
<CardContent className="p-6 space-y-4">
<div className="space-y-1">
<h2 className="text-base font-semibold font-fustat">
{STEP_TITLES[step]}
</h2>
{isPrefillingConfig && step === 1 && (
<p className="text-xs text-onSurface-default-tertiary">
Checking server configuration...
</p>
)}
</div>
{error && (
<p className="text-sm text-onSurface-danger-primary">{error}</p>
)}
{step === 0 && (
<form onSubmit={handleStep1} className="space-y-4">
<div className="space-y-1">
<Label htmlFor="setup-name">Name</Label>
<Input
id="setup-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
/>
</div>
<div className="space-y-1">
<Label htmlFor="setup-email">Email</Label>
<Input
id="setup-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@company.com"
/>
</div>
<div className="space-y-1">
<Label htmlFor="setup-password">Password</Label>
<Input
id="setup-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min 8 characters"
/>
</div>
<div className="space-y-1">
<Label htmlFor="setup-confirm-password">
Confirm Password
</Label>
<Input
id="setup-confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
<Button
type="submit"
disabled={isLoading || !name || !email || !password}
className="w-full"
>
{isLoading ? "Creating..." : "Create Admin Account"}
</Button>
</form>
)}
{step === 1 && (
<form onSubmit={handleStep2} className="space-y-4">
{!serverHasLlmKey && (
<div className="rounded-md border border-memBorder-primary bg-surface-default-secondary p-3">
<p className="text-xs text-onSurface-default-tertiary">
No LLM provider API key is configured on the server. Paste
one below to continue — it will be saved to the server and
used for all memory operations.
</p>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="setup-llm-provider">LLM Provider</Label>
<Select
value={llmProvider}
onValueChange={setLlmProvider}
disabled={!providers}
>
<SelectTrigger id="setup-llm-provider">
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{providers?.llm.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="setup-llm-model">Model</Label>
<Input
id="setup-llm-model"
value={llmModel}
onChange={(e) => setLlmModel(e.target.value)}
placeholder="gpt-4.1-nano-2025-04-14"
className="font-mono text-sm"
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="setup-llm-api-key">LLM API Key</Label>
<Input
id="setup-llm-api-key"
type="password"
value={llmApiKey}
onChange={(e) => setLlmApiKey(e.target.value)}
placeholder={
serverHasLlmKey
? "Leave blank to keep existing key"
: "sk-..."
}
className="font-mono text-sm"
/>
<p className="text-xs text-onSurface-default-tertiary">
Also used for the embedder when it shares the same provider.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="setup-embedder-provider">
Embedder Provider
</Label>
<Select
value={embedderProvider}
onValueChange={setEmbedderProvider}
disabled={!providers}
>
<SelectTrigger id="setup-embedder-provider">
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{providers?.embedder.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="setup-embedder-model">Model</Label>
<Input
id="setup-embedder-model"
value={embedderModel}
onChange={(e) => setEmbedderModel(e.target.value)}
placeholder="text-embedding-3-small"
className="font-mono text-sm"
/>
</div>
</div>
<p className="text-xs text-onSurface-default-tertiary">
Need another provider? Install its Python package and rebuild
the image. See{" "}
<a
href={SUPPORTED_PROVIDERS_URL}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-4 hover:text-onSurface-default-primary"
>
supported providers
</a>
.
</p>
<Button
type="submit"
disabled={
isLoading ||
!llmProvider ||
!embedderProvider ||
(!serverHasLlmKey && !llmApiKey)
}
className="w-full"
>
{isLoading ? "Saving..." : "Save & Continue"}
</Button>
</form>
)}
{step === 2 && !apiKey && (
<form onSubmit={handleStep3} className="space-y-4">
<div className="space-y-1">
<Label htmlFor="setup-key-label">Label for this key</Label>
<Input
id="setup-key-label"
value={keyLabel}
onChange={(e) => setKeyLabel(e.target.value)}
placeholder="My First Key"
/>
</div>
<Button type="submit" disabled={isLoading} className="w-full">
{isLoading ? "Generating..." : "Generate API Key"}
</Button>
</form>
)}
{step === 2 && apiKey && (
<form onSubmit={handleContinueToUseCase} className="space-y-4">
<div className="space-y-1">
<Label htmlFor="setup-api-key">Your API Key</Label>
<div className="flex gap-2">
<Input
id="setup-api-key"
value={apiKey}
readOnly
className="font-mono text-sm"
/>
<CopyToClipboard
text={apiKey}
onCopy={() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
<Button variant="outline" size="icon">
{copied ? (
<Check className="size-4" />
) : (
<Copy className="size-4" />
)}
</Button>
</CopyToClipboard>
</div>
<p className="text-xs text-onSurface-danger-primary">
Save this key. You will not see it again.
</p>
</div>
<Button type="submit" className="w-full">
Continue
</Button>
</form>
)}
{step === 3 && (
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="setup-use-case">Describe your use case</Label>
<textarea
id="setup-use-case"
value={useCase}
onChange={(e) => setUseCase(e.target.value)}
placeholder="e.g. A personal assistant that remembers my preferences"
className="flex w-full rounded-md border border-memBorder-primary bg-surface-default-primary px-3 py-2 text-sm placeholder:text-onSurface-default-tertiary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-memPurple-500 min-h-[80px] resize-y"
/>
</div>
<div className="flex flex-wrap gap-2">
{USE_CASE_PRESETS.map((preset) => (
<Button
key={preset}
type="button"
variant="outline"
size="sm"
onClick={() => setUseCase(preset)}
className={cn(
useCase === preset &&
"border-memPurple-500 text-memPurple-500",
)}
>
{preset}
</Button>
))}
</div>
<p className="text-xs text-onSurface-default-tertiary">
We&apos;ll generate custom instructions that tell the memory
extractor which facts to prioritize for your use case.
</p>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={handleContinueToQuickTest}
className="flex-1"
>
Skip
</Button>
<Button
type="button"
disabled={!useCase || isGeneratingInstructions}
className="flex-1"
onClick={async () => {
setError("");
setIsGeneratingInstructions(true);
try {
const res = await api.post(
MEMORY_ENDPOINTS.GENERATE_INSTRUCTIONS,
{
use_case: useCase,
},
);
setCustomInstructions(res.data.custom_instructions);
if (res.data.test_message) {
setTestMessage(res.data.test_message);
}
} catch (err) {
setError(
getErrorMessage(
err,
"Failed to generate instructions",
),
);
} finally {
setIsGeneratingInstructions(false);
}
}}
>
{isGeneratingInstructions
? "Generating instructions..."
: "Generate instructions"}
</Button>
</div>
{customInstructions && (
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="setup-instructions">
Generated instructions
</Label>
<textarea
id="setup-instructions"
value={customInstructions}
onChange={(e) => setCustomInstructions(e.target.value)}
className="flex w-full rounded-md border border-memBorder-primary bg-surface-default-primary px-3 py-2 text-sm placeholder:text-onSurface-default-tertiary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-memPurple-500 min-h-[120px] resize-y"
/>
</div>
<Button
type="button"
className="w-full"
onClick={async () => {
setError("");
setIsLoading(true);
try {
await api.post(MEMORY_ENDPOINTS.CONFIGURE, {
custom_instructions: customInstructions,
});
handleContinueToQuickTest();
} catch (err) {
setError(
getErrorMessage(err, "Failed to save instructions"),
);
} finally {
setIsLoading(false);
}
}}
disabled={isLoading}
>
{isLoading ? "Saving..." : "Save & Continue"}
</Button>
</div>
)}
</div>
)}
{step === 4 && (
<form
onSubmit={testSuccess ? handleGoToDashboard : handleTest}
className="space-y-4"
>
<div className="space-y-1">
<Label>Test your setup</Label>
{!apiUrl && (
<p className="text-xs text-onSurface-danger-primary">
NEXT_PUBLIC_API_URL is not set. Set it in .env and restart
before running this test.
</p>
)}
<pre className="text-xs bg-surface-default-secondary p-3 rounded font-mono overflow-x-auto">{`curl -X POST ${apiUrl}/memories \\
-H "X-API-Key: ${apiKey}" \\
-H "Content-Type: application/json" \\
-d '{"messages": [{"role": "user", "content": "${testMessage}"}], "user_id": "setup-test"}'`}</pre>
</div>
{!testSuccess ? (
<>
<Button
type="submit"
disabled={isLoading}
className="w-full"
>
{isLoading ? "Testing..." : "Run Test"}
</Button>
{error && (
<p className="text-xs text-onSurface-default-tertiary">
Provider credentials or model wrong? Fix them in{" "}
<a
href="/dashboard/configuration"
className="underline underline-offset-4 hover:text-onSurface-default-primary"
>
Configuration
</a>{" "}
and run the test again.
</p>
)}
</>
) : (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm text-onSurface-positive-primary">
<Check className="size-4" /> Memory created successfully
</div>
<Button type="submit" className="w-full">
Go to Dashboard
</Button>
</div>
)}
</form>
)}
</CardContent>
</Card>
</div>
</div>
);
}