feat: implement local authentication mode and update related components
This commit is contained in:
@@ -16,6 +16,8 @@ CORS_ORIGINS=http://localhost:3000
|
||||
DB_AUTO_MIGRATE=true
|
||||
LOG_LEVEL=INFO
|
||||
REQUEST_LOG_SLOW_MS=1000
|
||||
AUTH_MODE=local
|
||||
LOCAL_AUTH_TOKEN=change-me
|
||||
|
||||
# --- frontend settings ---
|
||||
# REQUIRED: Public URL used by the browser to reach the API.
|
||||
|
||||
22
README.md
22
README.md
@@ -30,15 +30,20 @@ Operational deep dives:
|
||||
- Production notes: [Production notes](./docs/production/README.md)
|
||||
- Troubleshooting: [Troubleshooting](./docs/troubleshooting/README.md)
|
||||
|
||||
## Authentication (Clerk)
|
||||
## Authentication
|
||||
|
||||
**Clerk is currently required**.
|
||||
Mission Control supports two auth modes via `AUTH_MODE`:
|
||||
|
||||
You must configure Clerk keys for:
|
||||
- the frontend (`NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`, `CLERK_SECRET_KEY`)
|
||||
- the backend (`CLERK_SECRET_KEY`)
|
||||
- `local`: shared bearer token auth for self-hosted deployments
|
||||
- `clerk`: Clerk JWT auth
|
||||
|
||||
See: [Deployment guide](./docs/deployment/README.md#clerk-auth-notes).
|
||||
`local` mode requires:
|
||||
- backend: `AUTH_MODE=local`, `LOCAL_AUTH_TOKEN=<token>`
|
||||
- frontend: `NEXT_PUBLIC_AUTH_MODE=local`, then enter the token in the login screen
|
||||
|
||||
`clerk` mode requires:
|
||||
- backend: `AUTH_MODE=clerk`, `CLERK_SECRET_KEY=<secret>`
|
||||
- frontend: `NEXT_PUBLIC_AUTH_MODE=clerk`, `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=<key>`
|
||||
|
||||
## Deployment modes
|
||||
|
||||
@@ -53,8 +58,9 @@ cp .env.example .env
|
||||
# NEXT_PUBLIC_API_URL must be reachable from the *browser* (host), not an internal Docker network name.
|
||||
# Missing/blank NEXT_PUBLIC_API_URL will break frontend API calls (e.g. Activity feed).
|
||||
|
||||
# REQUIRED: Clerk config.
|
||||
# Provide real Clerk values via frontend/.env (recommended) and backend/.env.
|
||||
# Auth defaults in .env.example are local mode.
|
||||
# For production, set LOCAL_AUTH_TOKEN to a strong random value.
|
||||
# For Clerk mode, set AUTH_MODE=clerk and provide Clerk keys.
|
||||
|
||||
docker compose -f compose.yml --env-file .env up -d --build
|
||||
```
|
||||
|
||||
@@ -8,8 +8,12 @@ DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/mission_contr
|
||||
CORS_ORIGINS=http://localhost:3000
|
||||
BASE_URL=
|
||||
|
||||
# Clerk (auth only)
|
||||
CLERK_SECRET_KEY=sk_test_your_clerk_secret_key
|
||||
# Auth mode: clerk or local.
|
||||
AUTH_MODE=local
|
||||
LOCAL_AUTH_TOKEN=change-me
|
||||
|
||||
# Clerk (auth only; used when AUTH_MODE=clerk)
|
||||
CLERK_SECRET_KEY=
|
||||
CLERK_API_URL=https://api.clerk.com
|
||||
CLERK_VERIFY_IAT=true
|
||||
CLERK_LEEWAY=10.0
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""User authentication helpers backed by Clerk JWT verification."""
|
||||
"""User authentication helpers for Clerk and local-token auth modes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from hmac import compare_digest
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import httpx
|
||||
@@ -29,6 +30,9 @@ logger = get_logger(__name__)
|
||||
security = HTTPBearer(auto_error=False)
|
||||
SECURITY_DEP = Depends(security)
|
||||
SESSION_DEP = Depends(get_session)
|
||||
LOCAL_AUTH_USER_ID = "local-auth-user"
|
||||
LOCAL_AUTH_EMAIL = "admin@home.local"
|
||||
LOCAL_AUTH_NAME = "Local User"
|
||||
|
||||
|
||||
class ClerkTokenPayload(BaseModel):
|
||||
@@ -45,6 +49,18 @@ class AuthContext:
|
||||
user: User | None = None
|
||||
|
||||
|
||||
def _extract_bearer_token(authorization: str | None) -> str | None:
|
||||
if not authorization:
|
||||
return None
|
||||
value = authorization.strip()
|
||||
if not value:
|
||||
return None
|
||||
if not value.lower().startswith("bearer "):
|
||||
return None
|
||||
token = value.split(" ", maxsplit=1)[1].strip()
|
||||
return token or None
|
||||
|
||||
|
||||
def _non_empty_str(value: object) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
@@ -228,6 +244,9 @@ async def _fetch_clerk_profile(clerk_user_id: str) -> tuple[str | None, str | No
|
||||
|
||||
async def delete_clerk_user(clerk_user_id: str) -> None:
|
||||
"""Delete a Clerk user via the official Clerk SDK."""
|
||||
if settings.auth_mode != "clerk":
|
||||
return
|
||||
|
||||
secret = settings.clerk_secret_key.strip()
|
||||
secret_kind = secret.split("_", maxsplit=1)[0] if "_" in secret else "unknown"
|
||||
server_url = _normalize_clerk_server_url(settings.clerk_api_url or "")
|
||||
@@ -343,6 +362,55 @@ async def _get_or_sync_user(
|
||||
return user
|
||||
|
||||
|
||||
async def _get_or_create_local_user(session: AsyncSession) -> User:
|
||||
defaults: dict[str, object] = {
|
||||
"email": LOCAL_AUTH_EMAIL,
|
||||
"name": LOCAL_AUTH_NAME,
|
||||
}
|
||||
user, _created = await crud.get_or_create(
|
||||
session,
|
||||
User,
|
||||
clerk_user_id=LOCAL_AUTH_USER_ID,
|
||||
defaults=defaults,
|
||||
)
|
||||
changed = False
|
||||
if not user.email:
|
||||
user.email = LOCAL_AUTH_EMAIL
|
||||
changed = True
|
||||
if not user.name:
|
||||
user.name = LOCAL_AUTH_NAME
|
||||
changed = True
|
||||
if changed:
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
from app.services.organizations import ensure_member_for_user
|
||||
|
||||
await ensure_member_for_user(session, user)
|
||||
return user
|
||||
|
||||
|
||||
async def _resolve_local_auth_context(
|
||||
*,
|
||||
request: Request,
|
||||
session: AsyncSession,
|
||||
required: bool,
|
||||
) -> AuthContext | None:
|
||||
token = _extract_bearer_token(request.headers.get("Authorization"))
|
||||
if token is None:
|
||||
if required:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
return None
|
||||
expected = settings.local_auth_token.strip()
|
||||
if not expected or not compare_digest(token, expected):
|
||||
if required:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
return None
|
||||
user = await _get_or_create_local_user(session)
|
||||
return AuthContext(actor_type="user", user=user)
|
||||
|
||||
|
||||
def _parse_subject(claims: dict[str, object]) -> str | None:
|
||||
payload = ClerkTokenPayload.model_validate(claims)
|
||||
return payload.sub
|
||||
@@ -353,7 +421,17 @@ async def get_auth_context(
|
||||
credentials: HTTPAuthorizationCredentials | None = SECURITY_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
) -> AuthContext:
|
||||
"""Resolve required authenticated user context from Clerk JWT headers."""
|
||||
"""Resolve required authenticated user context for the configured auth mode."""
|
||||
if settings.auth_mode == "local":
|
||||
local_auth = await _resolve_local_auth_context(
|
||||
request=request,
|
||||
session=session,
|
||||
required=True,
|
||||
)
|
||||
if local_auth is None: # pragma: no cover
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
return local_auth
|
||||
|
||||
request_state = await _authenticate_clerk_request(request)
|
||||
if request_state.status != AuthStatus.SIGNED_IN or not isinstance(request_state.payload, dict):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
@@ -388,6 +466,13 @@ async def get_auth_context_optional(
|
||||
"""Resolve user context if available, otherwise return `None`."""
|
||||
if request.headers.get("X-Agent-Token"):
|
||||
return None
|
||||
if settings.auth_mode == "local":
|
||||
return await _resolve_local_auth_context(
|
||||
request=request,
|
||||
session=session,
|
||||
required=False,
|
||||
)
|
||||
|
||||
request_state = await _authenticate_clerk_request(request)
|
||||
if request_state.status != AuthStatus.SIGNED_IN or not isinstance(request_state.payload, dict):
|
||||
return None
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Self
|
||||
from typing import Literal, Self
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
@@ -26,8 +26,12 @@ class Settings(BaseSettings):
|
||||
environment: str = "dev"
|
||||
database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/openclaw_agency"
|
||||
|
||||
# Auth mode: "clerk" for Clerk JWT auth, "local" for shared bearer token auth.
|
||||
auth_mode: Literal["clerk", "local"]
|
||||
local_auth_token: str = ""
|
||||
|
||||
# Clerk auth (auth only; roles stored in DB)
|
||||
clerk_secret_key: str = Field(min_length=1)
|
||||
clerk_secret_key: str = ""
|
||||
clerk_api_url: str = "https://api.clerk.com"
|
||||
clerk_verify_iat: bool = True
|
||||
clerk_leeway: float = 10.0
|
||||
@@ -47,8 +51,16 @@ class Settings(BaseSettings):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _defaults(self) -> Self:
|
||||
if self.auth_mode == "clerk":
|
||||
if not self.clerk_secret_key.strip():
|
||||
raise ValueError("CLERK_SECRET_KEY must be set and non-empty.")
|
||||
raise ValueError(
|
||||
"CLERK_SECRET_KEY must be set and non-empty when AUTH_MODE=clerk.",
|
||||
)
|
||||
elif self.auth_mode == "local":
|
||||
if not self.local_auth_token.strip():
|
||||
raise ValueError(
|
||||
"LOCAL_AUTH_TOKEN must be set and non-empty when AUTH_MODE=local.",
|
||||
)
|
||||
# In dev, default to applying Alembic migrations at startup to avoid
|
||||
# schema drift (e.g. missing newly-added columns).
|
||||
if "db_auto_migrate" not in self.model_fields_set and self.environment == "dev":
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
# ruff: noqa: INP001
|
||||
"""Pytest configuration shared across backend tests."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
# Tests should fail fast if auth-mode wiring breaks, but still need deterministic
|
||||
# defaults during import-time settings initialization.
|
||||
os.environ.setdefault("AUTH_MODE", "local")
|
||||
os.environ.setdefault("LOCAL_AUTH_TOKEN", "test-local-token")
|
||||
|
||||
@@ -21,6 +21,9 @@ class _FakeSession:
|
||||
async def test_get_auth_context_raises_401_when_clerk_signed_out(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(auth.settings, "auth_mode", "clerk")
|
||||
monkeypatch.setattr(auth.settings, "clerk_secret_key", "sk_test_dummy")
|
||||
|
||||
from clerk_backend_api.security.types import AuthStatus, RequestState
|
||||
|
||||
async def _fake_authenticate(_request: Any) -> RequestState:
|
||||
@@ -42,6 +45,9 @@ async def test_get_auth_context_raises_401_when_clerk_signed_out(
|
||||
async def test_get_auth_context_uses_request_state_payload_claims(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(auth.settings, "auth_mode", "clerk")
|
||||
monkeypatch.setattr(auth.settings, "clerk_secret_key", "sk_test_dummy")
|
||||
|
||||
from clerk_backend_api.security.types import AuthStatus, RequestState
|
||||
|
||||
async def _fake_authenticate(_request: Any) -> RequestState:
|
||||
@@ -82,6 +88,9 @@ async def test_get_auth_context_uses_request_state_payload_claims(
|
||||
async def test_get_auth_context_optional_returns_none_for_agent_token(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(auth.settings, "auth_mode", "clerk")
|
||||
monkeypatch.setattr(auth.settings, "clerk_secret_key", "sk_test_dummy")
|
||||
|
||||
async def _boom(_request: Any) -> Any: # pragma: no cover
|
||||
raise AssertionError("_authenticate_clerk_request should not be called")
|
||||
|
||||
@@ -93,3 +102,46 @@ async def test_get_auth_context_optional_returns_none_for_agent_token(
|
||||
session=_FakeSession(), # type: ignore[arg-type]
|
||||
)
|
||||
assert out is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_auth_context_local_mode_requires_valid_bearer_token(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(auth.settings, "auth_mode", "local")
|
||||
monkeypatch.setattr(auth.settings, "local_auth_token", "expected-token")
|
||||
|
||||
async def _fake_local_user(_session: Any) -> User:
|
||||
return User(clerk_user_id="local-auth-user", email="local@localhost", name="Local User")
|
||||
|
||||
monkeypatch.setattr(auth, "_get_or_create_local_user", _fake_local_user)
|
||||
|
||||
ctx = await auth.get_auth_context( # type: ignore[arg-type]
|
||||
request=SimpleNamespace(headers={"Authorization": "Bearer expected-token"}),
|
||||
credentials=None,
|
||||
session=_FakeSession(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert ctx.actor_type == "user"
|
||||
assert ctx.user is not None
|
||||
assert ctx.user.clerk_user_id == "local-auth-user"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_auth_context_optional_local_mode_returns_none_without_token(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(auth.settings, "auth_mode", "local")
|
||||
monkeypatch.setattr(auth.settings, "local_auth_token", "expected-token")
|
||||
|
||||
async def _boom(_session: Any) -> User: # pragma: no cover
|
||||
raise AssertionError("_get_or_create_local_user should not be called")
|
||||
|
||||
monkeypatch.setattr(auth, "_get_or_create_local_user", _boom)
|
||||
|
||||
out = await auth.get_auth_context_optional( # type: ignore[arg-type]
|
||||
request=SimpleNamespace(headers={}),
|
||||
credentials=None,
|
||||
session=_FakeSession(), # type: ignore[arg-type]
|
||||
)
|
||||
assert out is None
|
||||
|
||||
99
backend/tests/test_local_auth_integration.py
Normal file
99
backend/tests/test_local_auth_integration.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# ruff: noqa: INP001
|
||||
"""Integration tests for local auth mode on protected API routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.api.users import router as users_router
|
||||
from app.core import auth as auth_module
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_session
|
||||
|
||||
|
||||
async def _make_engine() -> AsyncEngine:
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.connect() as conn, conn.begin():
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
return engine
|
||||
|
||||
|
||||
def _build_test_app(
|
||||
session_maker: async_sessionmaker[AsyncSession],
|
||||
) -> FastAPI:
|
||||
app = FastAPI()
|
||||
api_v1 = APIRouter(prefix="/api/v1")
|
||||
api_v1.include_router(users_router)
|
||||
app.include_router(api_v1)
|
||||
|
||||
async def _override_get_session() -> AsyncSession:
|
||||
async with session_maker() as session:
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_session] = _override_get_session
|
||||
app.dependency_overrides[auth_module.get_session] = _override_get_session
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_auth_users_me_requires_and_accepts_valid_token(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
unique_suffix = uuid4().hex
|
||||
expected_user_id = f"local-auth-integration-{unique_suffix}"
|
||||
expected_email = f"local-{unique_suffix}@localhost"
|
||||
expected_name = "Local Integration User"
|
||||
|
||||
monkeypatch.setattr(settings, "auth_mode", "local")
|
||||
monkeypatch.setattr(settings, "local_auth_token", "integration-token")
|
||||
monkeypatch.setattr(auth_module, "LOCAL_AUTH_USER_ID", expected_user_id)
|
||||
monkeypatch.setattr(auth_module, "LOCAL_AUTH_EMAIL", expected_email)
|
||||
monkeypatch.setattr(auth_module, "LOCAL_AUTH_NAME", expected_name)
|
||||
|
||||
engine = await _make_engine()
|
||||
session_maker = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
app = _build_test_app(session_maker)
|
||||
|
||||
try:
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
missing = await client.get("/api/v1/users/me")
|
||||
assert missing.status_code == 401
|
||||
|
||||
invalid = await client.get(
|
||||
"/api/v1/users/me",
|
||||
headers={"Authorization": "Bearer wrong-token"},
|
||||
)
|
||||
assert invalid.status_code == 401
|
||||
|
||||
authorized = await client.get(
|
||||
"/api/v1/users/me",
|
||||
headers={"Authorization": "Bearer integration-token"},
|
||||
)
|
||||
assert authorized.status_code == 200
|
||||
payload = authorized.json()
|
||||
assert payload["clerk_user_id"] == expected_user_id
|
||||
assert payload["email"] == expected_email
|
||||
assert payload["name"] == expected_name
|
||||
|
||||
repeat = await client.get(
|
||||
"/api/v1/users/me",
|
||||
headers={"Authorization": "Bearer integration-token"},
|
||||
)
|
||||
assert repeat.status_code == 200
|
||||
assert repeat.json()["id"] == payload["id"]
|
||||
finally:
|
||||
await engine.dispose()
|
||||
@@ -30,6 +30,8 @@ services:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-mission_control}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000}
|
||||
DB_AUTO_MIGRATE: ${DB_AUTO_MIGRATE:-true}
|
||||
AUTH_MODE: ${AUTH_MODE}
|
||||
LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN:-change-me}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -41,6 +43,7 @@ services:
|
||||
context: ./frontend
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000}
|
||||
NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE}
|
||||
# Optional, user-managed env file.
|
||||
# IMPORTANT: do NOT load `.env.example` here because it contains non-empty
|
||||
# placeholder Clerk keys, which can accidentally flip Clerk "on".
|
||||
@@ -49,6 +52,7 @@ services:
|
||||
required: false
|
||||
environment:
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000}
|
||||
NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE}
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
Mission Control is the **web UI + HTTP API** for operating OpenClaw. It’s where you manage boards, tasks, agents, approvals, and (optionally) gateway connections.
|
||||
|
||||
> Auth note: **Clerk is required for production**. The codebase includes gating so CI/local can run without “real” keys, but real deployments should configure Clerk.
|
||||
> Auth note: Mission Control supports two auth modes: `local` (shared bearer token) and `clerk`.
|
||||
|
||||
## Components
|
||||
|
||||
@@ -48,14 +48,17 @@ Common UI-driven data shapes:
|
||||
- “boards/tasks” views → board/task CRUD + streams.
|
||||
- “activity feed” → activity/events endpoints.
|
||||
|
||||
### 2) Authentication (Clerk)
|
||||
### 2) Authentication (`local` or Clerk)
|
||||
|
||||
- **Frontend**: Clerk is enabled only when a publishable key is present/valid.
|
||||
- Gating/wrappers: `frontend/src/auth/clerkKey.ts`, `frontend/src/auth/clerk.tsx`.
|
||||
- **Frontend → backend**: API calls attach `Authorization: Bearer <token>` when available.
|
||||
- Token injection: `frontend/src/api/mutator.ts` (uses `window.Clerk.session.getToken()`).
|
||||
- **Backend**: validates inbound auth and resolves a user context.
|
||||
- Implementation: `backend/app/core/auth.py` (uses `clerk_backend_api` SDK with `CLERK_SECRET_KEY`).
|
||||
- **Frontend**:
|
||||
- `local`: token entry + token storage (`frontend/src/components/organisms/LocalAuthLogin.tsx`, `frontend/src/auth/localAuth.ts`).
|
||||
- `clerk`: Clerk wrappers/hooks (`frontend/src/auth/clerk.tsx`).
|
||||
- **Frontend → backend**:
|
||||
- API calls attach `Authorization: Bearer <token>` from local mode token or Clerk session token (`frontend/src/api/mutator.ts`).
|
||||
- **Backend**:
|
||||
- `local`: validates `LOCAL_AUTH_TOKEN`.
|
||||
- `clerk`: validates Clerk request state via `clerk_backend_api` + `CLERK_SECRET_KEY`.
|
||||
- Implementation: `backend/app/core/auth.py`.
|
||||
|
||||
### 3) Agent automation surface (`/api/v1/agent/*`)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Mission Control is the **web UI + HTTP API** for operating OpenClaw. It’s where you manage boards, tasks, agents, approvals, and (optionally) gateway connections.
|
||||
|
||||
> Auth note: **Clerk is required for now** (current product direction). The codebase includes gating so CI/local can run with placeholders, but real deployments should configure Clerk.
|
||||
> Auth note: Mission Control supports two auth modes: `local` (shared bearer token) and `clerk`.
|
||||
|
||||
At a high level:
|
||||
- The **frontend** is a Next.js app used by humans.
|
||||
@@ -29,10 +29,11 @@ flowchart LR
|
||||
- Routes/pages: `frontend/src/app/*` (Next.js App Router)
|
||||
- API utilities: `frontend/src/lib/*` and `frontend/src/api/*`
|
||||
|
||||
**Auth (Clerk, required)**
|
||||
- Clerk is required for real deployments and currently required by backend config (see `backend/app/core/config.py`).
|
||||
- Frontend uses Clerk when keys are configured; see `frontend/src/auth/clerkKey.ts` and `frontend/src/auth/clerk.tsx`.
|
||||
- Backend authenticates requests using the Clerk SDK and `CLERK_SECRET_KEY`; see `backend/app/core/auth.py`.
|
||||
**Auth (`local` or Clerk)**
|
||||
- `local` mode authenticates a shared bearer token (`LOCAL_AUTH_TOKEN`) and resolves a local user context.
|
||||
- `clerk` mode verifies Clerk JWTs using `CLERK_SECRET_KEY`.
|
||||
- Frontend mode switch + wrappers: `frontend/src/auth/clerk.tsx`, `frontend/src/auth/localAuth.ts`, and `frontend/src/components/providers/AuthProvider.tsx`.
|
||||
- Backend mode switch: `backend/app/core/config.py` and `backend/app/core/auth.py`.
|
||||
|
||||
|
||||
### Backend (FastAPI)
|
||||
@@ -64,9 +65,13 @@ Mission Control can call into an OpenClaw Gateway over WebSockets.
|
||||
2. Frontend calls backend endpoints under `/api/v1/*`.
|
||||
3. Backend reads/writes Postgres.
|
||||
|
||||
### Auth (Clerk — required)
|
||||
- **Frontend** uses Clerk when keys are configured (see `frontend/src/auth/*`).
|
||||
- **Backend** authenticates requests using the Clerk SDK and `CLERK_SECRET_KEY` (see `backend/app/core/auth.py`).
|
||||
### Auth (`local` or Clerk)
|
||||
- **Frontend**:
|
||||
- `local`: token entry screen + session storage token (`frontend/src/components/organisms/LocalAuthLogin.tsx`, `frontend/src/auth/localAuth.ts`).
|
||||
- `clerk`: Clerk wrappers/hooks (`frontend/src/auth/clerk.tsx`).
|
||||
- **Backend**:
|
||||
- `local`: validates `Authorization: Bearer <LOCAL_AUTH_TOKEN>`.
|
||||
- `clerk`: validates Clerk request state with SDK + `CLERK_SECRET_KEY`.
|
||||
### Agent access (X-Agent-Token)
|
||||
Automation/agents can use the “agent” API surface:
|
||||
- Endpoints under `/api/v1/agent/*` (router: `backend/app/api/agent.py`).
|
||||
@@ -92,7 +97,7 @@ Backend:
|
||||
Frontend:
|
||||
- `frontend/src/app/` — Next.js routes
|
||||
- `frontend/src/components/` — UI components
|
||||
- `frontend/src/auth/` — Clerk gating/wrappers
|
||||
- `frontend/src/auth/` — auth mode helpers (`clerk` and `local`)
|
||||
- `frontend/src/lib/` — utilities + API base
|
||||
|
||||
## Where to start reading code
|
||||
@@ -106,7 +111,7 @@ Backend:
|
||||
Frontend:
|
||||
1. `frontend/src/app/*` — main UI routes
|
||||
2. `frontend/src/lib/api-base.ts` — backend calls
|
||||
3. `frontend/src/auth/*` — Clerk integration (gated for CI/local)
|
||||
3. `frontend/src/auth/*` — auth mode integration (`local` + Clerk)
|
||||
|
||||
## Related docs
|
||||
- Self-host (Docker Compose): see repo root README: [Quick start (self-host with Docker Compose)](../../README.md#quick-start-self-host-with-docker-compose)
|
||||
|
||||
@@ -110,49 +110,58 @@ Instead, it supports an optional user-managed env file:
|
||||
|
||||
If present, Compose will load it.
|
||||
|
||||
## Clerk (auth) notes
|
||||
## Authentication modes
|
||||
|
||||
Clerk is currently required.
|
||||
Mission Control supports two deployment auth modes:
|
||||
|
||||
### Frontend (Clerk keys)
|
||||
- `AUTH_MODE=local`: shared bearer token auth (self-host default)
|
||||
- `AUTH_MODE=clerk`: Clerk JWT auth
|
||||
|
||||
Create `frontend/.env` (this file is **not** committed; `compose.yml` loads it if present):
|
||||
### Local mode (self-host default)
|
||||
|
||||
Set in `.env` (repo root):
|
||||
|
||||
```env
|
||||
# Frontend → Backend
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
|
||||
# Frontend → Clerk
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY
|
||||
CLERK_SECRET_KEY=YOUR_SECRET_KEY
|
||||
|
||||
# Optional (but recommended) redirects
|
||||
NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL=/boards
|
||||
NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL=/boards
|
||||
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/boards
|
||||
NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/boards
|
||||
AUTH_MODE=local
|
||||
LOCAL_AUTH_TOKEN=replace-with-strong-random-token
|
||||
```
|
||||
|
||||
### Backend (auth)
|
||||
Set frontend mode (optional override in `frontend/.env`):
|
||||
|
||||
The backend authenticates requests using the Clerk SDK and **`CLERK_SECRET_KEY`** (see `backend/app/core/auth.py`).
|
||||
```env
|
||||
NEXT_PUBLIC_AUTH_MODE=local
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
Create `backend/.env` (this file is **not** committed) with at least:
|
||||
Users enter `LOCAL_AUTH_TOKEN` in the local login screen.
|
||||
|
||||
### Clerk mode
|
||||
|
||||
Set in `.env` (repo root):
|
||||
|
||||
```env
|
||||
AUTH_MODE=clerk
|
||||
```
|
||||
|
||||
Create `backend/.env` with at least:
|
||||
|
||||
```env
|
||||
CLERK_SECRET_KEY=sk_test_your_real_key
|
||||
|
||||
# Optional tuning
|
||||
CLERK_API_URL=https://api.clerk.com
|
||||
CLERK_VERIFY_IAT=true
|
||||
CLERK_LEEWAY=10.0
|
||||
```
|
||||
|
||||
Then either:
|
||||
1) update `compose.yml` to load `backend/.env` (recommended), or
|
||||
2) pass the values via `services.backend.environment`.
|
||||
Create `frontend/.env` with at least:
|
||||
|
||||
**Security:** treat `CLERK_SECRET_KEY` like a password. Do not commit it.
|
||||
```env
|
||||
NEXT_PUBLIC_AUTH_MODE=clerk
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your_real_key
|
||||
CLERK_SECRET_KEY=sk_test_your_real_key
|
||||
```
|
||||
|
||||
**Security:** treat `LOCAL_AUTH_TOKEN` and `CLERK_SECRET_KEY` like passwords. Do not commit them.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -59,8 +59,10 @@ Recommended approach:
|
||||
|
||||
Secrets guidelines:
|
||||
|
||||
- **Clerk auth is required for now**: you must configure Clerk keys/JWKS for the app to work.
|
||||
- Never commit Clerk secret key.
|
||||
- Choose auth mode explicitly:
|
||||
- `AUTH_MODE=local`: set a strong `LOCAL_AUTH_TOKEN`
|
||||
- `AUTH_MODE=clerk`: configure Clerk keys
|
||||
- Never commit `LOCAL_AUTH_TOKEN` or Clerk secret key.
|
||||
- Prefer passing secrets as environment variables from the host (or use Docker secrets if you later
|
||||
migrate to Swarm/K8s).
|
||||
- Rotate secrets if they ever hit logs.
|
||||
@@ -75,7 +77,7 @@ sudo git clone https://github.com/abhi1693/openclaw-mission-control.git mission-
|
||||
cd mission-control
|
||||
|
||||
cp .env.example .env
|
||||
# edit .env with real values (domains, Clerk keys, etc.)
|
||||
# edit .env with real values (domains, auth mode + secrets, etc.)
|
||||
|
||||
docker compose -f compose.yml --env-file .env up -d --build
|
||||
```
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
# Must be reachable from the browser (host).
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY
|
||||
CLERK_SECRET_KEY=YOUR_SECRET_KEY
|
||||
# Auth mode: clerk or local.
|
||||
# - clerk: Clerk sign-in flow
|
||||
# - local: shared bearer token entered in UI
|
||||
NEXT_PUBLIC_AUTH_MODE=local
|
||||
|
||||
# Clerk auth (used when NEXT_PUBLIC_AUTH_MODE=clerk)
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
||||
CLERK_SECRET_KEY=
|
||||
NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL=/boards
|
||||
NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL=/boards
|
||||
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/boards
|
||||
|
||||
@@ -15,6 +15,8 @@ COPY . ./
|
||||
# Allows configuring the API URL at build time.
|
||||
ARG NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
ARG NEXT_PUBLIC_AUTH_MODE
|
||||
ENV NEXT_PUBLIC_AUTH_MODE=${NEXT_PUBLIC_AUTH_MODE}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
@@ -22,10 +24,12 @@ FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ARG NEXT_PUBLIC_AUTH_MODE
|
||||
|
||||
# If provided at runtime, Next will expose NEXT_PUBLIC_* to the browser as well
|
||||
# (but note some values may be baked at build time).
|
||||
ENV NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
ENV NEXT_PUBLIC_AUTH_MODE=${NEXT_PUBLIC_AUTH_MODE}
|
||||
|
||||
COPY --from=builder /app/.next ./.next
|
||||
# `public/` is optional in Next.js apps; repo may not have it.
|
||||
|
||||
@@ -4,7 +4,9 @@ This package is the **Next.js** web UI for OpenClaw Mission Control.
|
||||
|
||||
- Talks to the Mission Control **backend** over HTTP (typically `http://localhost:8000`).
|
||||
- Uses **React Query** for data fetching.
|
||||
- Can optionally enable **Clerk** authentication (disabled by default unless you provide a _real_ Clerk publishable key).
|
||||
- Supports two auth modes:
|
||||
- **local** shared bearer token mode (self-host default)
|
||||
- **clerk** mode
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -53,27 +55,23 @@ Example:
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
### Optional: Clerk authentication
|
||||
### Authentication mode
|
||||
|
||||
Clerk is **optional**.
|
||||
Set `NEXT_PUBLIC_AUTH_MODE` to one of:
|
||||
|
||||
The app only enables Clerk when `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` looks like a real key.
|
||||
Implementation detail: we gate on a conservative regex (`pk_test_...` / `pk_live_...`) in `src/auth/clerkKey.ts`.
|
||||
- `local` (default for self-host)
|
||||
- `clerk`
|
||||
|
||||
#### Env vars
|
||||
For `local` mode:
|
||||
|
||||
- users enter the token in the local login screen
|
||||
- requests use that token as `Authorization: Bearer ...`
|
||||
|
||||
For `clerk` mode, configure:
|
||||
|
||||
- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`
|
||||
- If **unset/blank/placeholder**, Clerk is treated as **disabled**.
|
||||
- `CLERK_SECRET_KEY`
|
||||
- Required only if you enable Clerk features that need server-side verification.
|
||||
- Redirect URLs (optional; used by Clerk UI flows):
|
||||
- `NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL`
|
||||
- `NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL`
|
||||
- `NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL`
|
||||
- `NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL`
|
||||
|
||||
**Important:** `frontend/.env.example` contains placeholder values like `YOUR_PUBLISHABLE_KEY`.
|
||||
Those placeholders are _not_ valid keys and are intentionally treated as “Clerk disabled”.
|
||||
- optional Clerk redirect env vars
|
||||
|
||||
## How the frontend talks to the backend
|
||||
|
||||
@@ -107,7 +105,7 @@ All Orval-generated requests go through the custom mutator (`src/api/mutator.ts`
|
||||
It will:
|
||||
|
||||
- set `Content-Type: application/json` when there is a body and you didn’t specify a content type
|
||||
- add `Authorization: Bearer <token>` automatically **if** Clerk is enabled and there is an active Clerk session in the browser
|
||||
- add `Authorization: Bearer <token>` automatically from local mode token or Clerk session
|
||||
- parse errors into an `ApiError` with status + parsed response body
|
||||
|
||||
## Common commands
|
||||
@@ -149,11 +147,11 @@ cp .env.example .env.local
|
||||
- Confirm `NEXT_PUBLIC_API_URL` points to the correct host/port.
|
||||
- If accessing from another device (LAN), use a reachable backend URL (not `localhost`).
|
||||
|
||||
### Clerk redirects / auth UI shows unexpectedly
|
||||
### Wrong auth mode UI
|
||||
|
||||
Clerk should be **off** unless you set a real `pk_test_...` or `pk_live_...` publishable key.
|
||||
|
||||
- Remove/blank `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` in your `.env.local` to force Clerk off.
|
||||
- Ensure `NEXT_PUBLIC_AUTH_MODE` matches backend `AUTH_MODE`.
|
||||
- For local mode, set `NEXT_PUBLIC_AUTH_MODE=local`.
|
||||
- For Clerk mode, set `NEXT_PUBLIC_AUTH_MODE=clerk` and a real Clerk publishable key.
|
||||
|
||||
### Dev server blocked by origin restrictions
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth";
|
||||
|
||||
type ClerkSession = {
|
||||
getToken: () => Promise<string>;
|
||||
};
|
||||
@@ -48,6 +50,12 @@ export const customFetch = async <T>(
|
||||
if (hasBody && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
if (isLocalAuthMode() && !headers.has("Authorization")) {
|
||||
const token = getLocalAuthToken();
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
}
|
||||
if (!headers.has("Authorization")) {
|
||||
const token = await resolveClerkToken();
|
||||
if (token) {
|
||||
|
||||
@@ -16,21 +16,33 @@ import {
|
||||
} from "@clerk/nextjs";
|
||||
|
||||
import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey";
|
||||
import { getLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth";
|
||||
|
||||
function hasLocalAuthToken(): boolean {
|
||||
return Boolean(getLocalAuthToken());
|
||||
}
|
||||
|
||||
export function isClerkEnabled(): boolean {
|
||||
// IMPORTANT: keep this in sync with AuthProvider; otherwise components like
|
||||
// <SignedOut/> may render without a <ClerkProvider/> and crash during prerender.
|
||||
if (isLocalAuthMode()) return false;
|
||||
return isLikelyValidClerkPublishableKey(
|
||||
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
export function SignedIn(props: { children: ReactNode }) {
|
||||
if (isLocalAuthMode()) {
|
||||
return hasLocalAuthToken() ? <>{props.children}</> : null;
|
||||
}
|
||||
if (!isClerkEnabled()) return null;
|
||||
return <ClerkSignedIn>{props.children}</ClerkSignedIn>;
|
||||
}
|
||||
|
||||
export function SignedOut(props: { children: ReactNode }) {
|
||||
if (isLocalAuthMode()) {
|
||||
return hasLocalAuthToken() ? null : <>{props.children}</>;
|
||||
}
|
||||
if (!isClerkEnabled()) return <>{props.children}</>;
|
||||
return <ClerkSignedOut>{props.children}</ClerkSignedOut>;
|
||||
}
|
||||
@@ -49,6 +61,13 @@ export function SignOutButton(
|
||||
}
|
||||
|
||||
export function useUser() {
|
||||
if (isLocalAuthMode()) {
|
||||
return {
|
||||
isLoaded: true,
|
||||
isSignedIn: hasLocalAuthToken(),
|
||||
user: null,
|
||||
} as const;
|
||||
}
|
||||
if (!isClerkEnabled()) {
|
||||
return { isLoaded: true, isSignedIn: false, user: null } as const;
|
||||
}
|
||||
@@ -56,6 +75,16 @@ export function useUser() {
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
if (isLocalAuthMode()) {
|
||||
const token = getLocalAuthToken();
|
||||
return {
|
||||
isLoaded: true,
|
||||
isSignedIn: Boolean(token),
|
||||
userId: token ? "local-user" : null,
|
||||
sessionId: token ? "local-session" : null,
|
||||
getToken: async () => token,
|
||||
} as const;
|
||||
}
|
||||
if (!isClerkEnabled()) {
|
||||
return {
|
||||
isLoaded: true,
|
||||
|
||||
43
frontend/src/auth/localAuth.ts
Normal file
43
frontend/src/auth/localAuth.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
let localToken: string | null = null;
|
||||
const STORAGE_KEY = "mc_local_auth_token";
|
||||
|
||||
export function isLocalAuthMode(): boolean {
|
||||
return process.env.NEXT_PUBLIC_AUTH_MODE === "local";
|
||||
}
|
||||
|
||||
export function setLocalAuthToken(token: string): void {
|
||||
localToken = token;
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.setItem(STORAGE_KEY, token);
|
||||
} catch {
|
||||
// Ignore storage failures (private mode / policy).
|
||||
}
|
||||
}
|
||||
|
||||
export function getLocalAuthToken(): string | null {
|
||||
if (localToken) return localToken;
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const stored = window.sessionStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
localToken = stored;
|
||||
return stored;
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage failures (private mode / policy).
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function clearLocalAuthToken(): void {
|
||||
localToken = null;
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore storage failures (private mode / policy).
|
||||
}
|
||||
}
|
||||
65
frontend/src/components/organisms/LocalAuthLogin.tsx
Normal file
65
frontend/src/components/organisms/LocalAuthLogin.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Lock } from "lucide-react";
|
||||
|
||||
import { setLocalAuthToken } from "@/auth/localAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export function LocalAuthLogin() {
|
||||
const [token, setToken] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const cleaned = token.trim();
|
||||
if (!cleaned) {
|
||||
setError("Bearer token is required.");
|
||||
return;
|
||||
}
|
||||
setLocalAuthToken(cleaned);
|
||||
setError(null);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="mx-auto rounded-full bg-slate-100 p-3 text-slate-700">
|
||||
<Lock className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="text-xl font-semibold text-slate-900">
|
||||
Local Authentication
|
||||
</h1>
|
||||
<p className="text-sm text-slate-600">
|
||||
Enter the shared local token configured as
|
||||
<code className="mx-1 rounded bg-slate-100 px-1 py-0.5 text-xs">
|
||||
LOCAL_AUTH_TOKEN
|
||||
</code>
|
||||
on the backend.
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<Input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
placeholder="Paste token"
|
||||
autoFocus
|
||||
/>
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
<Button type="submit" className="w-full">
|
||||
Continue
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { SignOutButton, useUser } from "@/auth/clerk";
|
||||
import { clearLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth";
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
@@ -36,13 +37,15 @@ export function UserMenu({
|
||||
}: UserMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { user } = useUser();
|
||||
if (!user) return null;
|
||||
const localMode = isLocalAuthMode();
|
||||
if (!user && !localMode) return null;
|
||||
|
||||
const avatarUrl = user.imageUrl ?? null;
|
||||
const avatarLabelSource = displayNameFromDb ?? user.id ?? "U";
|
||||
const avatarUrl = localMode ? null : (user?.imageUrl ?? null);
|
||||
const avatarLabelSource =
|
||||
displayNameFromDb ?? (localMode ? "Local User" : user?.id) ?? "U";
|
||||
const avatarLabel = avatarLabelSource.slice(0, 1).toUpperCase();
|
||||
const displayName = displayNameFromDb ?? "Account";
|
||||
const displayEmail = displayEmailFromDb ?? "";
|
||||
const displayName = displayNameFromDb ?? (localMode ? "Local User" : "Account");
|
||||
const displayEmail = displayEmailFromDb ?? (localMode ? "local@localhost" : "");
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -166,6 +169,20 @@ export function UserMenu({
|
||||
|
||||
<div className="my-2 h-px bg-[color:var(--neutral-200,var(--border))]" />
|
||||
|
||||
{localMode ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm font-semibold text-[color:var(--neutral-800,var(--text))] transition hover:bg-[color:var(--neutral-100,var(--surface-muted))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent-teal,var(--accent))] focus-visible:ring-offset-2"
|
||||
onClick={() => {
|
||||
clearLocalAuthToken();
|
||||
setOpen(false);
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
<LogOut className="h-4 w-4 text-[color:var(--neutral-700,var(--text-quiet))]" />
|
||||
Sign out
|
||||
</button>
|
||||
) : (
|
||||
<SignOutButton>
|
||||
<button
|
||||
type="button"
|
||||
@@ -176,6 +193,7 @@ export function UserMenu({
|
||||
Sign out
|
||||
</button>
|
||||
</SignOutButton>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -4,8 +4,17 @@ import { ClerkProvider } from "@clerk/nextjs";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey";
|
||||
import { getLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth";
|
||||
import { LocalAuthLogin } from "@/components/organisms/LocalAuthLogin";
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
if (isLocalAuthMode()) {
|
||||
if (!getLocalAuthToken()) {
|
||||
return <LocalAuthLogin />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY;
|
||||
const afterSignOutUrl =
|
||||
process.env.NEXT_PUBLIC_CLERK_AFTER_SIGN_OUT_URL ?? "/";
|
||||
|
||||
@@ -4,6 +4,7 @@ import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
|
||||
import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey";
|
||||
|
||||
const isClerkEnabled = () =>
|
||||
process.env.NEXT_PUBLIC_AUTH_MODE !== "local" &&
|
||||
isLikelyValidClerkPublishableKey(
|
||||
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user