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
+22 -4
View File
@@ -1,8 +1,8 @@
OPENAI_API_KEY=
NEO4J_URI=
NEO4J_USERNAME=
NEO4J_PASSWORD=
# Optional: other bundled LLM/embedder providers (set the key for the one you want to use)
# ANTHROPIC_API_KEY=
# GOOGLE_API_KEY=
POSTGRES_HOST=
POSTGRES_PORT=
@@ -11,5 +11,23 @@ POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_COLLECTION_NAME=
# Optional: set to enable API key authentication on all endpoints
ADMIN_API_KEY=
JWT_SECRET=
# Local development only. Leave false in production.
AUTH_DISABLED=false
DASHBOARD_URL=http://localhost:3000
APP_DB_NAME=mem0_app
# Default LLM and embedder models. Provider must be in BUNDLED_*_PROVIDERS
# (see server/main.py). Override to pin a specific model without editing code.
MEM0_DEFAULT_LLM_MODEL=gpt-4.1-nano-2025-04-14
MEM0_DEFAULT_EMBEDDER_MODEL=text-embedding-3-small
# Anonymous telemetry. Sends a single onboarding event per install
# (install UUID, email domain, server version, source). No PII.
# Set to false to opt out.
MEM0_TELEMETRY=true
# Days of request_logs history to keep when `make prune-logs` runs.
# Wire the prune command into cron/systemd in production.
REQUEST_LOG_RETENTION_DAYS=30
+57 -2
View File
@@ -1,7 +1,62 @@
.PHONY: up down clean logs seed wait-api wait-dashboard bootstrap health build run_local reset-admin-password prune-logs
OUTPUT ?= text
API_URL ?= http://localhost:8888
DASHBOARD_URL ?= http://localhost:3000
up:
@for port in 3000 8888; do \
if lsof -iTCP:$$port -sTCP:LISTEN >/dev/null 2>&1; then \
echo "error: port $$port is already in use."; \
echo "find the owning process with: lsof -iTCP:$$port -sTCP:LISTEN"; \
exit 1; \
fi; \
done
docker compose up -d --build
@$(MAKE) --no-print-directory wait-api
@$(MAKE) --no-print-directory wait-dashboard
@echo ""
@echo "Stack is ready."
@echo " Dashboard: $(DASHBOARD_URL) <- open this to run the setup wizard"
@echo " API: $(API_URL)"
down:
docker compose down
clean:
docker compose down -v
logs:
docker compose logs -f
seed:
@API_URL="$(API_URL)" DASHBOARD_URL="$(DASHBOARD_URL)" EMAIL="$(EMAIL)" PASSWORD="$(PASSWORD)" NAME="$(NAME)" OUTPUT="$(OUTPUT)" ./scripts/seed.sh
wait-api:
@echo "Waiting for API..."
@until curl -fsS "$(API_URL)/auth/setup-status" >/dev/null 2>&1; do sleep 2; done
wait-dashboard:
@echo "Waiting for dashboard..."
@until curl -fsS "$(DASHBOARD_URL)/api/health" >/dev/null 2>&1; do sleep 2; done
bootstrap: up wait-api wait-dashboard seed
health:
@echo "API: $$(curl -s -o /dev/null -w '%{http_code}' "$(API_URL)/docs")"
@echo "Dashboard: $$(curl -s -o /dev/null -w '%{http_code}' "$(DASHBOARD_URL)/api/health")"
@echo "Postgres: $$(docker compose exec -T postgres pg_isready -q && echo 'ok' || echo 'down')"
reset-admin-password:
@test -n "$(EMAIL)" || (echo "usage: make reset-admin-password EMAIL=<email> PASSWORD=<password>" && exit 2)
@test -n "$(PASSWORD)" || (echo "usage: make reset-admin-password EMAIL=<email> PASSWORD=<password>" && exit 2)
@docker compose exec -T -e EMAIL="$(EMAIL)" -e PASSWORD="$(PASSWORD)" -e PYTHONPATH=/app mem0 python scripts/reset_admin_password.py
prune-logs:
@docker compose exec -T -e REQUEST_LOG_RETENTION_DAYS="$(REQUEST_LOG_RETENTION_DAYS)" -e PYTHONPATH=/app mem0 python scripts/prune_request_logs.py
build:
docker build -t mem0-api-server .
run_local:
docker run -p 8000:8000 -v $(shell pwd):/app mem0-api-server --env-file .env
.PHONY: build run_local
+121 -12
View File
@@ -1,17 +1,126 @@
# Mem0 REST API Server
# Mem0 Self-Hosted Server
Mem0 provides a REST API server (written using FastAPI). Users can perform all operations through REST endpoints. The API also includes OpenAPI documentation, accessible at `/docs` when the server is running.
Mem0 ships a self-hosted FastAPI server plus a local dashboard. It is secure by default, supports dashboard login and API keys, and exposes OpenAPI docs at `/docs`.
## Features
## Quick Start
- **Create memories:** Create memories based on messages for a user, agent, or run.
- **Retrieve memories:** Get all memories for a given user, agent, or run.
- **Search memories:** Search stored memories based on a query.
- **Update memories:** Update an existing memory.
- **Delete memories:** Delete a specific memory or all memories for a user, agent, or run.
- **Reset memories:** Reset all memories for a user, agent, or run.
- **OpenAPI Documentation:** Accessible via `/docs` endpoint.
### Agent-first
## Running the server
Run one command; the terminal prints the admin email, password, and first API key.
Follow the instructions in the [docs](https://docs.mem0.ai/open-source/features/rest-api) to run the server.
```bash
cd server
make bootstrap
```
This starts the stack, waits for the API and dashboard to be ready, creates the first admin, and generates the first API key.
> The generated credentials print once in the `=== Ready ===` block. Save the password and API key before closing the terminal — the API key cannot be recovered afterwards.
> `make bootstrap` skips the setup wizard, so the use-case → custom-instructions step doesn't run. To add custom instructions afterwards, `POST /configure` with `{"custom_instructions": "..."}`, or run the Browser-first flow on a fresh install.
You can override the generated credentials:
```bash
cd server
make bootstrap EMAIL=admin@company.com PASSWORD='strong-password' NAME='Admin'
```
For machine-readable output:
```bash
cd server
OUTPUT=json make seed
```
Teardown:
```bash
# Stop the stack
cd server && make down
# Wipe all data (including the Postgres volume)
cd server && make clean
```
### Browser-first
Start the stack and finish setup by walking through the wizard in your browser.
```bash
cd server
make up
```
Then open `http://localhost:3000` and complete the setup wizard.
## Security Defaults
- Dashboard login uses JWTs.
- Programmatic access uses `X-API-Key`.
- Auth is enabled by default.
- `AUTH_DISABLED=true` exists for local development only and should not be used in production.
## Forgotten password
Reset an admin password from the host while the stack is running:
```bash
cd server
make reset-admin-password EMAIL=admin@example.com PASSWORD='new-strong-password'
```
This is the supported recovery path. Anyone with shell access to the host already has full access to the database and secrets, so this command does not expand the attack surface.
## Request log retention
The `request_logs` table is append-only and grows with traffic (~864k rows/day at 10 req/s). Prune it periodically:
```bash
cd server
make prune-logs # defaults to 30 days
make prune-logs REQUEST_LOG_RETENTION_DAYS=7 # shorter window
```
Wire the command into cron or a systemd timer in production. The `created_at` column uses a BRIN index, so range deletes stay cheap even on large tables.
## Local URLs
- Dashboard: `http://localhost:3000`
- API: `http://localhost:8888`
- OpenAPI docs: `http://localhost:8888/docs`
## Dashboard
Once logged in, the dashboard exposes:
- **Requests** — live audit log of API calls (method, path, status, latency).
- **Memories** — browse memories, filter by user ID.
- **Entities** — list every `user_id`, `agent_id`, and `run_id` that owns memories, with counts. Delete an entity to cascade-delete its memories.
- **API Keys** — create, label, and revoke per-user keys.
- **Configuration** — runtime LLM and embedder override. Changes persist to the app database and reapply on restart, layered over the values from your `.env`.
- **Settings** — account profile and password.
## Telemetry
Enabled by default, matching the Mem0 OSS library. Sends at most two events per install to the same anonymous PostHog project the library uses:
- `admin_registered` — fired when the first admin is created (wizard or direct API call). Properties: email domain, server version, install UUID.
- `onboarding_completed` — fired when the setup wizard reaches its final success state. Carries the same properties plus the freeform `use_case` the operator entered. API-only bootstraps never emit this event.
Set `MEM0_TELEMETRY=false` to opt out.
## Security headers
The dashboard sets the following response headers on every path (see `server/dashboard/next.config.mjs`):
- `X-Frame-Options: DENY`
- `Content-Security-Policy: frame-ancestors 'none'`
- `X-Content-Type-Options: nosniff`
- `Referrer-Policy: strict-origin-when-cross-origin`
Together these prevent iframe embedding, sniffing of mislabelled MIME types, and cross-origin referrer leaks. Harden further behind your own reverse proxy if needed.
## Reference
Additional product and API documentation lives at [docs.mem0.ai](https://docs.mem0.ai/open-source/overview).
+37
View File
@@ -0,0 +1,37 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+psycopg://postgres:postgres@postgres:5432/mem0_app
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+44
View File
@@ -0,0 +1,44 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from db import Base, _build_database_url
# Import models so Base.metadata picks up all tables
import models # noqa: F401
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
# Override sqlalchemy.url from alembic.ini with the runtime database URL
config.set_main_option("sqlalchemy.url", _build_database_url())
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,49 @@
"""Create users and api_keys tables
Revision ID: 001
Revises: None
Create Date: 2026-04-10
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("password_hash", sa.Text(), nullable=False),
sa.Column("role", sa.String(20), nullable=False, server_default="admin"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
sa.UniqueConstraint("email"),
)
op.create_index("ix_users_email", "users", ["email"])
op.create_table(
"api_keys",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("key_prefix", sa.String(12), nullable=False),
sa.Column("key_hash", sa.Text(), nullable=False),
sa.Column("label", sa.String(255), nullable=False),
sa.Column("created_by", sa.Uuid(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("api_keys")
op.drop_table("users")
@@ -0,0 +1,36 @@
"""Create request_logs table
Revision ID: 002
Revises: 001
Create Date: 2026-04-13
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "002"
down_revision: Union[str, None] = "001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"request_logs",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("method", sa.String(16), nullable=False),
sa.Column("path", sa.String(512), nullable=False),
sa.Column("status_code", sa.Integer(), nullable=False),
sa.Column("latency_ms", sa.Float(), nullable=False),
sa.Column("auth_type", sa.String(32), nullable=False, server_default="none"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_request_logs_created_at", "request_logs", ["created_at"])
def downgrade() -> None:
op.drop_index("ix_request_logs_created_at", table_name="request_logs")
op.drop_table("request_logs")
@@ -0,0 +1,30 @@
"""Create settings table
Revision ID: 003
Revises: 002
Create Date: 2026-04-15
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "003"
down_revision: Union[str, None] = "002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"settings",
sa.Column("key", sa.String(255), primary_key=True),
sa.Column("value", sa.Text(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("settings")
@@ -0,0 +1,31 @@
"""Enforce at-most-one admin via partial unique index
Revision ID: 004
Revises: 003
Create Date: 2026-04-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "004"
down_revision: Union[str, None] = "003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
"ix_users_only_one_admin",
"users",
["role"],
unique=True,
postgresql_where=sa.text("role = 'admin'"),
)
def downgrade() -> None:
op.drop_index("ix_users_only_one_admin", table_name="users")
@@ -0,0 +1,39 @@
"""Create refresh_token_jtis table for single-use refresh tokens
Revision ID: 005
Revises: 004
Create Date: 2026-04-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "005"
down_revision: Union[str, None] = "004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"refresh_token_jtis",
sa.Column("jti", sa.Uuid(), primary_key=True),
sa.Column(
"user_id",
sa.Uuid(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_refresh_token_jtis_expires_at", "refresh_token_jtis", ["expires_at"])
def downgrade() -> None:
op.drop_index("ix_refresh_token_jtis_expires_at", table_name="refresh_token_jtis")
op.drop_table("refresh_token_jtis")
@@ -0,0 +1,26 @@
"""Swap request_logs.created_at btree index for a BRIN index
Revision ID: 006
Revises: 005
Create Date: 2026-04-21
"""
from typing import Sequence, Union
from alembic import op
revision: str = "006"
down_revision: Union[str, None] = "005"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_index("ix_request_logs_created_at", table_name="request_logs")
op.execute("CREATE INDEX ix_request_logs_created_at ON request_logs USING BRIN (created_at)")
def downgrade() -> None:
op.drop_index("ix_request_logs_created_at", table_name="request_logs")
op.create_index("ix_request_logs_created_at", "request_logs", ["created_at"])
+186
View File
@@ -0,0 +1,186 @@
import os
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, Request
from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy import select, update
from sqlalchemy.orm import Session
from db import get_db
from models import APIKey, RefreshTokenJti, User
JWT_SECRET = os.environ.get("JWT_SECRET", "")
JWT_ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_DAYS = 30
ADMIN_API_KEY = os.environ.get("ADMIN_API_KEY", "")
AUTH_DISABLED = os.environ.get("AUTH_DISABLED", "").lower() in {"1", "true", "yes", "on"}
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def dummy_verify_password() -> None:
"""Burn the same bcrypt cycles as a real verify so login timing doesn't leak whether an email exists."""
pwd_context.dummy_verify()
def generate_api_key() -> tuple[str, str, str]:
"""Returns (full_key, prefix, hash)."""
raw = secrets.token_urlsafe(32)
full_key = f"m0sk_{raw}"
prefix = full_key[:12]
key_hash = pwd_context.hash(full_key)
return full_key, prefix, key_hash
def verify_api_key_hash(plain_key: str, hashed: str) -> bool:
return pwd_context.verify(plain_key, hashed)
def _get_secret() -> str:
if not JWT_SECRET:
raise HTTPException(status_code=500, detail="JWT_SECRET is not configured.")
return JWT_SECRET
def create_access_token(user_id: str, role: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {"sub": user_id, "role": role, "exp": expire, "type": "access"}
return jwt.encode(payload, _get_secret(), algorithm=JWT_ALGORITHM)
def create_refresh_token(user_id: str, db: Session) -> str:
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
jti = uuid.uuid4()
db.add(RefreshTokenJti(jti=jti, user_id=uuid.UUID(user_id), expires_at=expire))
db.commit()
payload = {"sub": user_id, "exp": expire, "jti": str(jti), "type": "refresh"}
return jwt.encode(payload, _get_secret(), algorithm=JWT_ALGORITHM)
def consume_refresh_jti(jti: str, db: Session) -> None:
"""Atomically mark a refresh token's jti as used. Raises 401 if missing, already used, or expired.
The conditional UPDATE closes the read-check-write race: concurrent replays of the same
token race on a single row, so at most one update affects a row and the rest see rowcount 0.
"""
try:
jti_uuid = uuid.UUID(jti)
except (TypeError, ValueError):
raise HTTPException(status_code=401, detail="Refresh token is no longer valid.")
now = datetime.now(timezone.utc)
result = db.execute(
update(RefreshTokenJti)
.where(
RefreshTokenJti.jti == jti_uuid,
RefreshTokenJti.used_at.is_(None),
RefreshTokenJti.expires_at > now,
)
.values(used_at=now)
)
if result.rowcount == 0:
raise HTTPException(status_code=401, detail="Refresh token is no longer valid.")
db.commit()
def decode_token(token: str) -> dict:
try:
return jwt.decode(token, _get_secret(), algorithms=[JWT_ALGORITHM])
except JWTError:
raise HTTPException(status_code=401, detail="Invalid or expired token.")
bearer_scheme = HTTPBearer(auto_error=False)
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
def _mark_auth_type(request: Request, auth_type: str) -> None:
request.state.auth_type = auth_type
def _get_default_user(db: Session) -> User | None:
return db.scalar(select(User).order_by(User.created_at.asc()))
def _resolve_user_from_jwt(token: str, db: Session) -> User:
payload = decode_token(token)
if payload.get("type") != "access":
raise HTTPException(status_code=401, detail="Invalid token type.")
user = db.get(User, payload.get("sub"))
if user is None:
raise HTTPException(status_code=401, detail="User not found.")
return user
def _resolve_user_from_api_key(key: str, db: Session) -> User:
prefix = key[:12] if len(key) >= 12 else key
candidates = (
db.execute(select(APIKey).where(APIKey.key_prefix == prefix, APIKey.revoked_at.is_(None))).scalars().all()
)
for candidate in candidates:
if verify_api_key_hash(key, candidate.key_hash):
candidate.last_used_at = datetime.now(timezone.utc)
db.commit()
user = db.get(User, candidate.created_by)
if user is None:
raise HTTPException(status_code=401, detail="API key owner not found.")
return user
raise HTTPException(status_code=401, detail="Invalid API key.")
async def verify_auth(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
x_api_key: str | None = Depends(api_key_header),
db: Session = Depends(get_db),
) -> User | None:
"""Authenticate via JWT, X-API-Key, or legacy ADMIN_API_KEY. Returns User or None."""
if credentials is not None:
_mark_auth_type(request, "bearer")
return _resolve_user_from_jwt(credentials.credentials, db)
if x_api_key is not None:
if ADMIN_API_KEY and secrets.compare_digest(x_api_key, ADMIN_API_KEY):
_mark_auth_type(request, "admin_api_key")
return None
_mark_auth_type(request, "api_key")
return _resolve_user_from_api_key(x_api_key, db)
if AUTH_DISABLED:
_mark_auth_type(request, "disabled")
return None
raise HTTPException(
status_code=401,
detail="Authentication required. Provide a Bearer token or X-API-Key header.",
headers={"WWW-Authenticate": "Bearer"},
)
async def require_auth(
request: Request,
user: User | None = Depends(verify_auth),
db: Session = Depends(get_db),
) -> User:
"""Like verify_auth but guarantees a non-None User. Use for endpoints that require auth."""
if user is None:
if getattr(request.state, "auth_type", "none") in {"admin_api_key", "disabled"}:
default_user = _get_default_user(db)
if default_user is not None:
return default_user
raise HTTPException(status_code=401, detail="Authentication required.")
return user
+6
View File
@@ -0,0 +1,6 @@
node_modules
.next
.env.local
.env.example
.env
.storybook
+3
View File
@@ -0,0 +1,3 @@
NEXT_PUBLIC_API_URL=http://localhost:8888
API_INTERNAL_URL=http://localhost:8888
NEXT_PUBLIC_INSTANCE_NAME=Mem0
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}
+39
View File
@@ -0,0 +1,39 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
*storybook.log
storybook-static
+51
View File
@@ -0,0 +1,51 @@
FROM node:20-alpine AS base
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Dependencies
FROM base AS deps
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile --network-timeout 600000; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i; \
else npm install; \
fi
# Build
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# Placeholder values replaced at runtime by entrypoint.sh
ENV NEXT_PUBLIC_API_URL=NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_INSTANCE_NAME=NEXT_PUBLIC_INSTANCE_NAME
RUN npm run build
# Production
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --chown=nextjs:nodejs entrypoint.sh /home/nextjs/entrypoint.sh
RUN chmod +x /home/nextjs/entrypoint.sh
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENTRYPOINT ["/home/nextjs/entrypoint.sh"]
CMD ["node", "server.js"]
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
set -e
cd /app
# Swap NEXT_PUBLIC_* placeholders baked into .next/ at build time with the
# container's real env values. Each placeholder must be declared in the
# Dockerfile as `ENV NAME=NAME` so Next.js inlines the literal name. Currently:
# NEXT_PUBLIC_API_URL, NEXT_PUBLIC_INSTANCE_NAME
# New NEXT_PUBLIC_* vars must be added to the Dockerfile or this loop won't substitute them.
printenv | grep '^NEXT_PUBLIC_' | while IFS='=' read -r key value; do
escaped=$(printf '%s' "$value" | sed -e 's/[\\&|]/\\&/g')
find .next/ -type f -exec sed -i "s|$key|$escaped|g" {} \;
done
echo "Done replacing env variables NEXT_PUBLIC_ with real values"
exec "$@"
+41
View File
@@ -0,0 +1,41 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
eslint: {
ignoreDuringBuilds: false,
},
typescript: {
ignoreBuildErrors: false,
},
experimental: {
optimizePackageImports: ["@/components", "@/lib", "@/utils"],
},
compress: true,
images: {
formats: ["image/webp", "image/avif"],
},
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Content-Security-Policy", value: "frame-ancestors 'none'" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
],
},
];
},
redirects: async () => {
return [
{
source: "/settings",
destination: "/dashboard/settings",
permanent: false,
},
];
},
};
export default nextConfig;
+83
View File
@@ -0,0 +1,83 @@
{
"name": "mem0-dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "prettier --check .",
"format": "prettier --write .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hookform/resolvers": "^3.6.0",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.1.1",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.2",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.0.7",
"@reduxjs/toolkit": "^2.3.0",
"@tanstack/react-table": "^8.17.3",
"axios": "^1.6.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
"cmdk": "^1.0.0",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.12",
"lodash": "^4.17.21",
"lucide-react": "^0.542.0",
"next": "15.2.6",
"next-themes": "^0.4.4",
"react": "^19.0.0",
"react-copy-to-clipboard": "^5.1.0",
"react-day-picker": "8.10.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.52.0",
"react-redux": "^9.1.2",
"react-syntax-highlighter": "^15.5.0",
"recharts": "^2.15.0",
"redux": "^5.0.1",
"sonner": "^1.4.41",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7",
"use-debounce": "^10.0.4",
"uuid": "^9.0.1",
"vaul": "^0.9.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.13",
"@types/lodash": "^4.17.7",
"@types/node": "22.5.4",
"@types/react": "^19.0.0",
"@types/react-copy-to-clipboard": "^5.0.7",
"@types/react-dom": "^19.0.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^9.0.8",
"autoprefixer": "^10.0.1",
"eslint": "^8",
"eslint-config-next": "15.2.6",
"postcss": "^8",
"prettier": "^3.5.2",
"tailwindcss": "^3.3.0",
"typescript": "^5.6.3"
}
}
+7967
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 808 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="109" height="21" viewBox="0 0 109 21" fill="none">
<g clip-path="url(#clip0_8821_113361)">
<path d="M66.3702 3.90968V17.4541H70.1925V3.90968H66.3702ZM36.2969 3.88672V17.4427H40.1536V6.91701L43.1609 6.92848C44.1481 6.92848 44.8368 7.16953 45.3074 7.67458C45.9157 8.31737 46.1568 9.3619 46.1568 11.2558V17.4427H49.8987V9.95877C49.8987 4.60986 46.4897 3.88672 43.1609 3.88672H36.2969ZM72.5341 3.90968V17.4541H78.7324C82.0382 17.4541 83.1171 16.9032 84.2764 15.675C85.1029 14.8141 85.6309 12.9087 85.6309 10.8311C85.6309 8.92572 85.1832 7.22692 84.3912 6.17091C82.9909 4.27698 80.9477 3.90968 77.8945 3.90968H72.5341ZM76.3219 6.84814H77.9633C80.3508 6.84814 81.8889 7.91562 81.8889 10.6934C81.8889 13.4712 80.3508 14.5501 77.9633 14.5501H76.3219V6.84814ZM60.8606 3.90968L57.6696 14.6419L54.6163 3.90968H50.4841L54.8459 17.4541H60.3555L64.7632 3.90968H60.8606ZM87.4215 17.4541H91.2438V3.90968H87.4215V17.4541ZM98.1423 3.90968L92.8049 17.4427H96.5698L97.4192 15.0437H103.732L104.536 17.4312H108.634L103.25 3.90968H98.1423ZM100.622 6.37752L102.94 12.7136H98.2341L100.622 6.37752Z" fill="#FCFCFC"/>
<path d="M11.6276 6.15924V4.29974C11.8112 4.28826 11.9949 4.27679 12.1785 4.27679C17.2749 4.11609 20.6151 8.66152 20.6151 8.66152C20.6151 8.66152 17.0109 13.6661 13.1427 13.6661C12.6262 13.6661 12.1211 13.5857 11.6391 13.425V7.77769C13.6248 8.01873 14.0266 8.89109 15.2088 10.8768L17.8603 8.65004C17.8603 8.65004 15.9205 6.11332 12.6606 6.11332C12.3163 6.10185 11.9719 6.1248 11.6276 6.15924ZM11.6276 0.00683594V2.7846L12.1785 2.75016C19.2607 2.50912 23.8865 8.55822 23.8865 8.55822C23.8865 8.55822 18.5835 15.0091 13.0624 15.0091C12.5803 15.0091 12.1097 14.9631 11.6391 14.8828V16.6045C12.0293 16.6505 12.4311 16.6849 12.8213 16.6849C17.9636 16.6849 21.6826 14.0564 25.2868 10.9572C25.8837 11.4393 28.3286 12.5986 28.8336 13.1036C25.4131 15.9732 17.4356 18.2804 12.9132 18.2804C12.477 18.2804 12.0638 18.2574 11.6505 18.2115V20.6335H31.1867V0.00683594L11.6276 0.00683594ZM11.6276 13.425V14.8943C6.87554 14.0449 5.55553 9.0977 5.55553 9.0977C5.55553 9.0977 7.83972 6.57246 11.6276 6.15924V7.76621H11.6161C9.63034 7.52516 8.06929 9.38466 8.06929 9.38466C8.06929 9.38466 8.95312 12.5183 11.6276 13.425ZM3.19098 8.89109C3.19098 8.89109 6.00318 4.73592 11.6391 4.29974V2.7846C5.39483 3.28965 0 8.56969 0 8.56969C0 8.56969 3.05324 17.408 11.6276 18.2115V16.6045C5.33744 15.824 3.19098 8.89109 3.19098 8.89109Z" fill="#FCFCFC"/>
</g>
<defs>
<clipPath id="clip0_8821_113361">
<rect width="108.631" height="20.6399" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.8 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.9 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.8 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
@@ -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>
);
}
@@ -0,0 +1,36 @@
import { useState } from "react";
import { CheckIcon, CopyIcon } from "@radix-ui/react-icons";
import { CopyToClipboard } from "react-copy-to-clipboard";
interface CopyButtonProps {
textToCopy: string;
}
const CopyButton: React.FC<CopyButtonProps> = ({ textToCopy }) => {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
};
return (
<CopyToClipboard text={textToCopy} onCopy={handleCopy}>
<button
type="button"
className="absolute top-2 right-2 bg-white hover:bg-gray-100 p-2 text-black rounded-md"
>
{copied ? (
<CheckIcon className="size-3" />
) : (
<CopyIcon className="size-3" />
)}
</button>
</CopyToClipboard>
);
};
export default CopyButton;
@@ -0,0 +1,10 @@
import React from "react";
export function Spinner({ size = "small", className = "" }) {
const spinnerSize = size === "small" ? "w-4 h-4" : "w-6 h-6";
return (
<div
className={`${spinnerSize} ${className} animate-spin rounded-full border-y-2 border-memBorder-primary`}
></div>
);
}
@@ -0,0 +1,30 @@
"use client";
import React, { useEffect, useState } from "react";
import Image from "next/image";
import { useTheme } from "next-themes";
export default function ThemeAwareLogo({
width = 120,
height = 40,
}: {
width?: number;
height?: number;
}) {
const [mounted, setMounted] = useState(false);
const { theme, resolvedTheme } = useTheme();
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <div style={{ width, height }} />;
}
const currentTheme = theme === "system" ? resolvedTheme : theme;
const logoSrc =
currentTheme === "dark" ? "/images/dark.svg" : "/images/light.svg";
return <Image src={logoSrc} alt="Mem0.ai" width={width} height={height} />;
}
@@ -0,0 +1,38 @@
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { createPortal } from "react-dom";
export const TruncatedText: React.FC<{ text: string; limit?: number }> = ({
text,
limit = 100,
}) => {
if (!text) return null;
if (text.length <= limit) return <span>{text}</span>;
return (
<HoverCard>
<HoverCardTrigger asChild>
<span className="cursor-pointer">{text?.slice(0, limit)}...</span>
</HoverCardTrigger>
{createPortal(
<HoverCardContent
className="text-foreground p-4 shadow-lg rounded-lg z-[9999]"
style={{
minWidth: "400px",
maxWidth: "600px",
width: "auto", // Allows it to adapt based on content
transform: "translateY(10px)",
whiteSpace: "normal", // Wraps text to prevent overflow
overflowWrap: "break-word", // Breaks long words to fit within max-width
}}
>
<p className="">{text}</p>
</HoverCardContent>,
document.body,
)}
</HoverCard>
);
};
@@ -0,0 +1,45 @@
"use client";
import Image from "next/image";
import { useTheme } from "next-themes";
interface EmptyStateProps {
title: string;
description?: string;
image?: "memories" | "requests";
children?: React.ReactNode;
}
export function EmptyState({
title,
description,
image = "memories",
children,
}: EmptyStateProps) {
const { resolvedTheme } = useTheme();
const src =
resolvedTheme === "dark"
? `/images/no-${image}-dark.svg`
: `/images/no-${image}.svg`;
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Image
src={src}
alt=""
width={120}
height={120}
className="mb-4 opacity-80"
/>
<p className="text-sm font-medium text-onSurface-default-primary">
{title}
</p>
{description && (
<p className="text-xs text-onSurface-default-tertiary mt-1 max-w-xs">
{description}
</p>
)}
{children}
</div>
);
}
@@ -0,0 +1,63 @@
"use client";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Lock } from "lucide-react";
interface LockedPageProps {
title: string;
description: string;
previewContent: React.ReactNode;
utmMedium: string;
}
export function LockedPage({
title,
description,
previewContent,
utmMedium,
}: LockedPageProps) {
const utm = `utm_source=oss&utm_medium=${utmMedium}`;
const cloudUrl = `https://app.mem0.ai?${utm}`;
const salesUrl = `https://app.mem0.ai/enterprise?${utm}`;
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold font-fustat flex items-center gap-2">
{title}
<Lock className="size-4 text-onSurface-default-tertiary" />
</h1>
<p className="text-sm text-onSurface-default-secondary mt-1">
{description}
</p>
</div>
<div className="opacity-60 pointer-events-none select-none">
{previewContent}
</div>
<Card className="border-memBorder-primary">
<CardContent className="flex flex-col sm:flex-row items-center gap-4 py-6">
<div className="flex-1">
<p className="text-sm font-medium">
This feature is available in Mem0 Cloud and Enterprise.
</p>
</div>
<div className="flex gap-3">
<Button variant="default" asChild>
<a href={cloudUrl} target="_blank" rel="noopener noreferrer">
Start free on Cloud
</a>
</Button>
<Button variant="outline" asChild>
<a href={salesUrl} target="_blank" rel="noopener noreferrer">
Talk to sales
</a>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,70 @@
"use client";
import { useEffect, useState } from "react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
interface UpgradeBannerProps {
id: string;
message: string;
ctaLabel: string;
ctaUrl: string;
variant: "cloud" | "enterprise";
dismissible?: boolean;
}
export function UpgradeBanner({
id,
message,
ctaLabel,
ctaUrl,
variant,
dismissible = true,
}: UpgradeBannerProps) {
const [mounted, setMounted] = useState(false);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
setDismissed(localStorage.getItem(`nudge_${id}_dismissed`) === "true");
setMounted(true);
}, [id]);
if (!mounted || dismissed) return null;
const handleDismiss = () => {
localStorage.setItem(`nudge_${id}_dismissed`, "true");
setDismissed(true);
};
return (
<div
className={cn(
"flex items-center gap-3 px-4 py-2.5 rounded-md typo-body-xs font-fustat",
variant === "cloud"
? "bg-memGold-100 border-l-2 border-memGold-500"
: "bg-memRed-100 border-l-2 border-memRed-400",
)}
>
<p className="flex-1 text-onSurface-default-secondary">{message}</p>
<a
href={ctaUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(
"text-xs font-medium whitespace-nowrap underline",
variant === "cloud" ? "text-memGold-700" : "text-memRed-600",
)}
>
{ctaLabel}
</a>
{dismissible && (
<button
onClick={handleDismiss}
className="p-0.5 rounded hover:bg-surface-default-secondary-hover text-onSurface-default-tertiary"
>
<X className="size-3.5" />
</button>
)}
</div>
);
}
@@ -0,0 +1,192 @@
import { ReactNode } from "react";
import { LucideIcon } from "lucide-react";
interface Column<T> {
key: keyof T;
label: string;
icon?: LucideIcon;
render?(value: T[keyof T], row: T): ReactNode;
className?: string;
width?: number | "auto";
align?: "left" | "center" | "right";
cellVariant?: "default" | "flush";
headerVariant?: "default" | "check";
}
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
className?: string;
getRowKey?: (row: T, rowIndex: number) => string | number;
onRowClick?: (row: T, rowIndex: number) => void;
getRowClassName?: (row: T, rowIndex: number) => string | undefined;
}
const classes = {
tableHeaderRow: "h-[38px] border-b border-memBorder-primary",
tableHeaderCell:
"w-[230px] h-[38px] p-2 align-middle bg-surface-default-fg-secondary text-onSurface-default-secondary",
tableHeaderCheckCell:
"w-[40px] h-[38px] p-2 align-middle bg-surface-default-fg-secondary",
tableHeaderCheckWrap: "flex items-center gap-2",
tableHeaderCheckBox:
"box-border flex h-4 w-4 items-center gap-2.5 rounded-sm border border-memBorder-primary p-1",
tableHeaderDivider:
"w-px self-stretch border border-memBorder-primary shrink-0",
tableRow:
"h-[38px] border-t border-memBorder-primary bg-surface-default-primary hover:bg-surface-default-primary-hover",
tableCell:
"text-sm font-medium text-onSurface-default-primary px-4 py-2 justify-start align-middle font-[Fustat] leading-[140%] tracking-normal",
tableCellFlush: "align-middle",
tableCellBase: "text-sm px-6",
tableCellPadding: "",
} as const;
export function DataTable<T>({
data,
columns,
className = "",
getRowKey,
onRowClick,
getRowClassName,
}: DataTableProps<T>) {
const minHeight = data.length > 0 ? Math.max(76, 38 + data.length * 38) : 100;
// Proportional column widths so table fits container (width numbers treated as relative weights)
const totalWeight = columns.reduce(
(sum, col) => sum + (typeof col.width === "number" ? col.width : 100),
0,
);
return (
<div
className={`min-w-0 max-w-full overflow-hidden transition-all duration-300 ease-in-out ${className}`}
style={{ minHeight: `${minHeight}px` }}
>
<table className="table-fixed w-full">
<colgroup>
{columns.map((col, i) => {
const weight = typeof col.width === "number" ? col.width : 100;
const pct =
totalWeight > 0
? (weight / totalWeight) * 100
: 100 / columns.length;
return <col key={i} style={{ width: `${pct}%` }} />;
})}
</colgroup>
<thead>
<tr className={classes.tableHeaderRow}>
{columns.map((column, index) => {
const isLastColumn = index === columns.length - 1;
if (column.headerVariant === "check") {
return (
<th key={index} className={classes.tableHeaderCheckCell}>
<div className="flex h-full items-stretch justify-between">
<div className={classes.tableHeaderCheckWrap}>
<span className={classes.tableHeaderCheckBox} />
</div>
{!isLastColumn && (
<div className={classes.tableHeaderDivider} />
)}
</div>
</th>
);
}
const Icon = column.icon;
const relevantClasses = column.className
? column.className
.split(" ")
.filter(
(c) =>
c.startsWith("w-") ||
c.startsWith("min-w-") ||
c.startsWith("max-w-") ||
c.startsWith("text-center") ||
c.startsWith("text-left") ||
c.startsWith("text-right"),
)
.join(" ")
: "";
const baseHeaderClass = classes.tableHeaderCell;
const alignClass =
column.align === "center"
? "text-center"
: column.align === "right"
? "text-right"
: "";
const mergedRelevantClasses =
`${relevantClasses} ${alignClass}`.trim();
const hasCustomAlignment =
mergedRelevantClasses.includes("text-center") ||
mergedRelevantClasses.includes("text-right");
const headerClassName = hasCustomAlignment
? `${baseHeaderClass.replace("text-left", "")} ${mergedRelevantClasses}`.trim()
: mergedRelevantClasses
? `${baseHeaderClass} ${mergedRelevantClasses}`.trim()
: baseHeaderClass;
const headerCellClassName = `${headerClassName} min-w-0`;
const flexAlignment = mergedRelevantClasses.includes(
"text-center",
)
? "justify-center"
: mergedRelevantClasses.includes("text-right")
? "justify-end"
: "";
return (
<th key={index} className={headerCellClassName}>
<div className="flex h-full min-w-0 items-stretch justify-between">
<div
className={`flex min-w-0 flex-1 items-center gap-2 overflow-hidden ${flexAlignment}`}
>
{Icon && <Icon className="size-4 shrink-0" />}
<span className="truncate font-[Fustat] text-sm font-semibold leading-[18px] text-onSurface-default-secondary">
{column.label}
</span>
</div>
{!isLastColumn && (
<div className={classes.tableHeaderDivider} />
)}
</div>
</th>
);
})}
</tr>
</thead>
<tbody className="transition-all duration-300 ease-in-out">
{data.map((row, rowIndex) => (
<tr
key={getRowKey ? String(getRowKey(row, rowIndex)) : rowIndex}
className={`${classes.tableRow} ${
onRowClick ? "cursor-pointer" : ""
} ${getRowClassName ? (getRowClassName(row, rowIndex) ?? "") : ""} animate-fade-in`}
onClick={onRowClick ? () => onRowClick(row, rowIndex) : undefined}
>
{columns.map((column, colIndex) => {
const value = row[column.key];
const baseCellClass =
column.cellVariant === "flush"
? classes.tableCellFlush
: classes.tableCell;
const cellClassName = `${column.className || baseCellClass} min-w-0 overflow-hidden`;
return (
<td key={colIndex} className={cellClassName}>
<div className="min-w-0 overflow-hidden">
{column.render
? column.render(value, row)
: String(value)}
</div>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
export { classes as tableClasses };
@@ -0,0 +1,77 @@
import { Plus, RefreshCw, SearchCode, Trash, UserRound } from "lucide-react";
import { cn } from "@/lib/utils";
interface EventBadgeProps {
event: string;
type?: string;
count?: number;
label?: string;
icon?: React.ElementType;
showIcon?: boolean;
variant?: "primary" | "secondary";
}
type BadgeVariant = "add" | "update" | "retrieved" | "delete" | "user";
const getBadgeConfig = (
type: string,
): { variant: BadgeVariant; icon: React.ElementType } => {
switch (type.toUpperCase()) {
case "ADD":
return { variant: "add", icon: Plus };
case "UPDATE":
return { variant: "update", icon: RefreshCw };
case "SEARCH":
case "GET_ALL":
case "GET":
return { variant: "retrieved", icon: SearchCode };
case "DELETE":
return { variant: "delete", icon: Trash };
case "USER":
case "USERS":
return { variant: "user", icon: UserRound };
default:
return { variant: "add", icon: Plus };
}
};
export function EventBadge({
event,
type,
count,
label,
icon,
showIcon = true,
variant = "primary",
}: EventBadgeProps) {
const resolvedType = (type ?? event).toUpperCase();
const { variant: badgeVariant, icon: DefaultIcon } =
getBadgeConfig(resolvedType);
const Icon = icon ?? DefaultIcon;
const content = label ?? (typeof count === "number" ? String(count) : "");
if (count === 0 && !label) {
return null;
}
return (
<div
className={cn(
"inline-flex min-w-0 max-w-full items-center justify-center gap-1 overflow-hidden rounded-sm px-1.5 py-0.5",
variant === "secondary"
? "bg-surface-default-fg-secondary"
: "bg-surface-default-tertiary",
)}
aria-label={`${event} ${badgeVariant} count`}
>
{showIcon && (
<Icon className="size-3.5 shrink-0 text-onSurface-default-secondary" />
)}
{content && (
<span className="min-w-0 truncate font-dm-mono text-xs font-normal leading-[18px] text-onSurface-default-secondary">
{content}
</span>
)}
</div>
);
}
@@ -0,0 +1,38 @@
import { Skeleton } from "@/components/ui/skeleton";
interface TableSkeletonProps {
rows?: number;
columns?: number;
}
export function TableSkeleton({ rows = 5, columns = 5 }: TableSkeletonProps) {
return (
<div className="w-full">
{/* Table Header */}
<div className="border-b border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/50">
<div className="flex items-center gap-4 px-4 py-3">
<Skeleton className="size-4 rounded" />
{[...Array(columns - 1)].map((_, i) => (
<Skeleton key={i} className="h-4 flex-1" />
))}
</div>
</div>
{/* Table Rows */}
<div className="divide-y divide-zinc-200 dark:divide-zinc-800">
{[...Array(rows)].map((_, rowIndex) => (
<div key={rowIndex} className="flex items-center gap-4 px-4 py-3">
<Skeleton className="size-4 rounded" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-3 w-3/4" />
</div>
{[...Array(columns - 2)].map((_, colIndex) => (
<Skeleton key={colIndex} className="h-4 w-24" />
))}
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,11 @@
"use client";
import * as React from "react";
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from "next-themes";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
@@ -0,0 +1,57 @@
"use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "@radix-ui/react-icons";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
@@ -0,0 +1,141 @@
"use client";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-surface-default-primary p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props}
/>
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className,
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
@@ -0,0 +1,59 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-surface-default-primary text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };
@@ -0,0 +1,50 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
@@ -0,0 +1,37 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
@@ -0,0 +1,73 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 font-fustat font-semibold",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-onSurface-danger-primary hover:bg-onSurface-danger-secondary dark:bg-onSurface-danger-secondary dark:hover:bg-onSurface-danger-primary text-white",
outline:
"border border-memBorder-primary bg-surface-default-primary hover:bg-neutral-100 hover:text-neutral-800",
secondary:
"bg-surface-default-fg-secondary text-onSurface-default-primary hover:bg-surface-default-fg-secondary-hover border border-memBorder-primary",
ghost: "hover:bg-accent/30 hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
grey: "bg-accent/30 text-accent-foreground hover:bg-accent/80",
primary:
"bg-onSurface-default-primary text-surface-default-primary hover:bg-onSurface-default-primary/80",
tertiary:
"bg-surface-default-tertiary text-onSurface-default-primary hover:bg-surface-default-tertiary-hover",
surfaceSecondary:
"bg-surface-default-secondary text-onSurface-default-primary hover:bg-surface-default-secondary-hover border border-memBorder-primary",
surfacePrimary:
"bg-surface-default-primary text-onSurface-default-primary hover:bg-surface-default-primary-hover",
transparent:
"bg-transparent text-onSurface-default-primary hover:bg-transparent/80 hover:text-white",
subtle:
"bg-transparent text-onSurface-default-primary hover:bg-surface-default-fg-secondary-hover border-0",
brand:
"bg-surface-default-brand text-onSurface-default-primary hover:bg-surface-default-brand-hover",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-lg px-8",
icon: "size-9",
xl: "h-16 rounded-md px-5 ",
xs: "h-[30px] px-3 text-[10px]",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
export type { ButtonProps };
@@ -0,0 +1,72 @@
"use client";
import * as React from "react";
import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md",
),
day: cn(
buttonVariants({ variant: "ghost" }),
"h-8 w-8 p-0 font-normal aria-selected:opacity-100",
),
day_range_start: "day-range-start",
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeftIcon className="size-4" />,
IconRight: ({ ...props }) => <ChevronRightIcon className="size-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

Some files were not shown because too many files have changed in this diff Show More