Self-hosted dashboard and admin auth (#4837)
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
.env.local
|
||||
.env.example
|
||||
.env
|
||||
.storybook
|
||||
@@ -0,0 +1,3 @@
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8888
|
||||
API_INTERNAL_URL=http://localhost:8888
|
||||
NEXT_PUBLIC_INSTANCE_NAME=Mem0
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 "$@"
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 808 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
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 |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -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">
|
||||
"Mem0 allowed us to unlock true personalized tutoring for
|
||||
every student, and it took us just a weekend to integrate."
|
||||
</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'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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
};
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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 };
|
||||