docs: resolve merge conflicts with master
This commit is contained in:
@@ -16,6 +16,9 @@ CORS_ORIGINS=http://localhost:3000
|
|||||||
DB_AUTO_MIGRATE=true
|
DB_AUTO_MIGRATE=true
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
REQUEST_LOG_SLOW_MS=1000
|
REQUEST_LOG_SLOW_MS=1000
|
||||||
|
AUTH_MODE=local
|
||||||
|
# REQUIRED when AUTH_MODE=local (must be non-placeholder and at least 50 chars).
|
||||||
|
LOCAL_AUTH_TOKEN=
|
||||||
|
|
||||||
# --- frontend settings ---
|
# --- frontend settings ---
|
||||||
# REQUIRED: Public URL used by the browser to reach the API.
|
# REQUIRED: Public URL used by the browser to reach the API.
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -30,15 +30,20 @@ Operational deep dives:
|
|||||||
- Production notes: [Production notes](./docs/production/README.md)
|
- Production notes: [Production notes](./docs/production/README.md)
|
||||||
- Troubleshooting: [Troubleshooting](./docs/troubleshooting/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:
|
- `local`: shared bearer token auth for self-hosted deployments
|
||||||
- the frontend (`NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`, `CLERK_SECRET_KEY`)
|
- `clerk`: Clerk JWT auth
|
||||||
- the backend (`CLERK_SECRET_KEY`)
|
|
||||||
|
|
||||||
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
|
## Deployment modes
|
||||||
|
|
||||||
@@ -49,12 +54,16 @@ See: [Deployment guide](./docs/deployment/README.md#clerk-auth-notes).
|
|||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
|
# REQUIRED for local auth mode:
|
||||||
|
# set LOCAL_AUTH_TOKEN to a non-placeholder value with at least 50 characters.
|
||||||
|
|
||||||
# REQUIRED: the browser must be able to reach the backend.
|
# REQUIRED: the browser must be able to reach the backend.
|
||||||
# NEXT_PUBLIC_API_URL must be reachable from the *browser* (host), not an internal Docker network name.
|
# 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).
|
# Missing/blank NEXT_PUBLIC_API_URL will break frontend API calls (e.g. Activity feed).
|
||||||
|
|
||||||
# REQUIRED: Clerk config.
|
# Auth defaults in .env.example are local mode.
|
||||||
# Provide real Clerk values via frontend/.env (recommended) and backend/.env.
|
# For production, set LOCAL_AUTH_TOKEN to a random value with at least 50 characters.
|
||||||
|
# For Clerk mode, set AUTH_MODE=clerk and provide Clerk keys.
|
||||||
|
|
||||||
docker compose -f compose.yml --env-file .env up -d --build
|
docker compose -f compose.yml --env-file .env up -d --build
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -8,8 +8,13 @@ DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/mission_contr
|
|||||||
CORS_ORIGINS=http://localhost:3000
|
CORS_ORIGINS=http://localhost:3000
|
||||||
BASE_URL=
|
BASE_URL=
|
||||||
|
|
||||||
# Clerk (auth only)
|
# Auth mode: clerk or local.
|
||||||
CLERK_SECRET_KEY=sk_test_your_clerk_secret_key
|
AUTH_MODE=local
|
||||||
|
# REQUIRED when AUTH_MODE=local (must be non-placeholder and at least 50 chars).
|
||||||
|
LOCAL_AUTH_TOKEN=
|
||||||
|
|
||||||
|
# Clerk (auth only; used when AUTH_MODE=clerk)
|
||||||
|
CLERK_SECRET_KEY=
|
||||||
CLERK_API_URL=https://api.clerk.com
|
CLERK_API_URL=https://api.clerk.com
|
||||||
CLERK_VERIFY_IAT=true
|
CLERK_VERIFY_IAT=true
|
||||||
CLERK_LEEWAY=10.0
|
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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from hmac import compare_digest
|
||||||
from typing import TYPE_CHECKING, Literal
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -15,6 +16,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|||||||
from pydantic import BaseModel, ValidationError
|
from pydantic import BaseModel, ValidationError
|
||||||
from starlette.concurrency import run_in_threadpool
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
from app.core.auth_mode import AuthMode
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.db import crud
|
from app.db import crud
|
||||||
@@ -29,6 +31,9 @@ logger = get_logger(__name__)
|
|||||||
security = HTTPBearer(auto_error=False)
|
security = HTTPBearer(auto_error=False)
|
||||||
SECURITY_DEP = Depends(security)
|
SECURITY_DEP = Depends(security)
|
||||||
SESSION_DEP = Depends(get_session)
|
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):
|
class ClerkTokenPayload(BaseModel):
|
||||||
@@ -45,6 +50,18 @@ class AuthContext:
|
|||||||
user: User | None = None
|
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:
|
def _non_empty_str(value: object) -> str | None:
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
return None
|
return None
|
||||||
@@ -228,6 +245,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:
|
async def delete_clerk_user(clerk_user_id: str) -> None:
|
||||||
"""Delete a Clerk user via the official Clerk SDK."""
|
"""Delete a Clerk user via the official Clerk SDK."""
|
||||||
|
if settings.auth_mode != AuthMode.CLERK:
|
||||||
|
return
|
||||||
|
|
||||||
secret = settings.clerk_secret_key.strip()
|
secret = settings.clerk_secret_key.strip()
|
||||||
secret_kind = secret.split("_", maxsplit=1)[0] if "_" in secret else "unknown"
|
secret_kind = secret.split("_", maxsplit=1)[0] if "_" in secret else "unknown"
|
||||||
server_url = _normalize_clerk_server_url(settings.clerk_api_url or "")
|
server_url = _normalize_clerk_server_url(settings.clerk_api_url or "")
|
||||||
@@ -343,6 +363,55 @@ async def _get_or_sync_user(
|
|||||||
return 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:
|
def _parse_subject(claims: dict[str, object]) -> str | None:
|
||||||
payload = ClerkTokenPayload.model_validate(claims)
|
payload = ClerkTokenPayload.model_validate(claims)
|
||||||
return payload.sub
|
return payload.sub
|
||||||
@@ -353,7 +422,17 @@ async def get_auth_context(
|
|||||||
credentials: HTTPAuthorizationCredentials | None = SECURITY_DEP,
|
credentials: HTTPAuthorizationCredentials | None = SECURITY_DEP,
|
||||||
session: AsyncSession = SESSION_DEP,
|
session: AsyncSession = SESSION_DEP,
|
||||||
) -> AuthContext:
|
) -> AuthContext:
|
||||||
"""Resolve required authenticated user context from Clerk JWT headers."""
|
"""Resolve required authenticated user context for the configured auth mode."""
|
||||||
|
if settings.auth_mode == AuthMode.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)
|
request_state = await _authenticate_clerk_request(request)
|
||||||
if request_state.status != AuthStatus.SIGNED_IN or not isinstance(request_state.payload, dict):
|
if request_state.status != AuthStatus.SIGNED_IN or not isinstance(request_state.payload, dict):
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
@@ -388,6 +467,13 @@ async def get_auth_context_optional(
|
|||||||
"""Resolve user context if available, otherwise return `None`."""
|
"""Resolve user context if available, otherwise return `None`."""
|
||||||
if request.headers.get("X-Agent-Token"):
|
if request.headers.get("X-Agent-Token"):
|
||||||
return None
|
return None
|
||||||
|
if settings.auth_mode == AuthMode.LOCAL:
|
||||||
|
return await _resolve_local_auth_context(
|
||||||
|
request=request,
|
||||||
|
session=session,
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
request_state = await _authenticate_clerk_request(request)
|
request_state = await _authenticate_clerk_request(request)
|
||||||
if request_state.status != AuthStatus.SIGNED_IN or not isinstance(request_state.payload, dict):
|
if request_state.status != AuthStatus.SIGNED_IN or not isinstance(request_state.payload, dict):
|
||||||
return None
|
return None
|
||||||
|
|||||||
12
backend/app/core/auth_mode.py
Normal file
12
backend/app/core/auth_mode.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""Shared auth-mode enum values."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class AuthMode(str, Enum):
|
||||||
|
"""Supported authentication modes for backend and frontend."""
|
||||||
|
|
||||||
|
CLERK = "clerk"
|
||||||
|
LOCAL = "local"
|
||||||
@@ -8,8 +8,19 @@ from typing import Self
|
|||||||
from pydantic import Field, model_validator
|
from pydantic import Field, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
from app.core.auth_mode import AuthMode
|
||||||
|
|
||||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
DEFAULT_ENV_FILE = BACKEND_ROOT / ".env"
|
DEFAULT_ENV_FILE = BACKEND_ROOT / ".env"
|
||||||
|
LOCAL_AUTH_TOKEN_MIN_LENGTH = 50
|
||||||
|
LOCAL_AUTH_TOKEN_PLACEHOLDERS = frozenset(
|
||||||
|
{
|
||||||
|
"change-me",
|
||||||
|
"changeme",
|
||||||
|
"replace-me",
|
||||||
|
"replace-with-strong-random-token",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@@ -26,8 +37,12 @@ class Settings(BaseSettings):
|
|||||||
environment: str = "dev"
|
environment: str = "dev"
|
||||||
database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/openclaw_agency"
|
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: AuthMode
|
||||||
|
local_auth_token: str = ""
|
||||||
|
|
||||||
# Clerk auth (auth only; roles stored in DB)
|
# 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_api_url: str = "https://api.clerk.com"
|
||||||
clerk_verify_iat: bool = True
|
clerk_verify_iat: bool = True
|
||||||
clerk_leeway: float = 10.0
|
clerk_leeway: float = 10.0
|
||||||
@@ -47,8 +62,21 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _defaults(self) -> Self:
|
def _defaults(self) -> Self:
|
||||||
if not self.clerk_secret_key.strip():
|
if self.auth_mode == AuthMode.CLERK:
|
||||||
raise ValueError("CLERK_SECRET_KEY must be set and non-empty.")
|
if not self.clerk_secret_key.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"CLERK_SECRET_KEY must be set and non-empty when AUTH_MODE=clerk.",
|
||||||
|
)
|
||||||
|
elif self.auth_mode == AuthMode.LOCAL:
|
||||||
|
token = self.local_auth_token.strip()
|
||||||
|
if (
|
||||||
|
not token
|
||||||
|
or len(token) < LOCAL_AUTH_TOKEN_MIN_LENGTH
|
||||||
|
or token.lower() in LOCAL_AUTH_TOKEN_PLACEHOLDERS
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"LOCAL_AUTH_TOKEN must be at least 50 characters and non-placeholder when AUTH_MODE=local.",
|
||||||
|
)
|
||||||
# In dev, default to applying Alembic migrations at startup to avoid
|
# In dev, default to applying Alembic migrations at startup to avoid
|
||||||
# schema drift (e.g. missing newly-added columns).
|
# schema drift (e.g. missing newly-added columns).
|
||||||
if "db_auto_migrate" not in self.model_fields_set and self.environment == "dev":
|
if "db_auto_migrate" not in self.model_fields_set and self.environment == "dev":
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
# ruff: noqa: INP001
|
# ruff: noqa: INP001
|
||||||
"""Pytest configuration shared across backend tests."""
|
"""Pytest configuration shared across backend tests."""
|
||||||
|
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
if str(ROOT) not in sys.path:
|
if str(ROOT) not in sys.path:
|
||||||
sys.path.insert(0, str(ROOT))
|
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, regardless of shell env.
|
||||||
|
os.environ["AUTH_MODE"] = "local"
|
||||||
|
os.environ["LOCAL_AUTH_TOKEN"] = "test-local-token-0123456789-0123456789-0123456789x"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import pytest
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from app.core import auth
|
from app.core import auth
|
||||||
|
from app.core.auth_mode import AuthMode
|
||||||
from app.models.users import User
|
from app.models.users import User
|
||||||
|
|
||||||
|
|
||||||
@@ -21,6 +22,9 @@ class _FakeSession:
|
|||||||
async def test_get_auth_context_raises_401_when_clerk_signed_out(
|
async def test_get_auth_context_raises_401_when_clerk_signed_out(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
monkeypatch.setattr(auth.settings, "auth_mode", AuthMode.CLERK)
|
||||||
|
monkeypatch.setattr(auth.settings, "clerk_secret_key", "sk_test_dummy")
|
||||||
|
|
||||||
from clerk_backend_api.security.types import AuthStatus, RequestState
|
from clerk_backend_api.security.types import AuthStatus, RequestState
|
||||||
|
|
||||||
async def _fake_authenticate(_request: Any) -> RequestState:
|
async def _fake_authenticate(_request: Any) -> RequestState:
|
||||||
@@ -42,6 +46,9 @@ async def test_get_auth_context_raises_401_when_clerk_signed_out(
|
|||||||
async def test_get_auth_context_uses_request_state_payload_claims(
|
async def test_get_auth_context_uses_request_state_payload_claims(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
monkeypatch.setattr(auth.settings, "auth_mode", AuthMode.CLERK)
|
||||||
|
monkeypatch.setattr(auth.settings, "clerk_secret_key", "sk_test_dummy")
|
||||||
|
|
||||||
from clerk_backend_api.security.types import AuthStatus, RequestState
|
from clerk_backend_api.security.types import AuthStatus, RequestState
|
||||||
|
|
||||||
async def _fake_authenticate(_request: Any) -> RequestState:
|
async def _fake_authenticate(_request: Any) -> RequestState:
|
||||||
@@ -82,6 +89,9 @@ async def test_get_auth_context_uses_request_state_payload_claims(
|
|||||||
async def test_get_auth_context_optional_returns_none_for_agent_token(
|
async def test_get_auth_context_optional_returns_none_for_agent_token(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
monkeypatch.setattr(auth.settings, "auth_mode", AuthMode.CLERK)
|
||||||
|
monkeypatch.setattr(auth.settings, "clerk_secret_key", "sk_test_dummy")
|
||||||
|
|
||||||
async def _boom(_request: Any) -> Any: # pragma: no cover
|
async def _boom(_request: Any) -> Any: # pragma: no cover
|
||||||
raise AssertionError("_authenticate_clerk_request should not be called")
|
raise AssertionError("_authenticate_clerk_request should not be called")
|
||||||
|
|
||||||
@@ -93,3 +103,46 @@ async def test_get_auth_context_optional_returns_none_for_agent_token(
|
|||||||
session=_FakeSession(), # type: ignore[arg-type]
|
session=_FakeSession(), # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
assert out is None
|
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", AuthMode.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", AuthMode.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
|
||||||
|
|||||||
70
backend/tests/test_config_auth_mode.py
Normal file
70
backend/tests/test_config_auth_mode.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# ruff: noqa: INP001
|
||||||
|
"""Settings validation tests for auth-mode configuration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.core.auth_mode import AuthMode
|
||||||
|
from app.core.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_mode_requires_non_empty_token() -> None:
|
||||||
|
with pytest.raises(
|
||||||
|
ValidationError,
|
||||||
|
match="LOCAL_AUTH_TOKEN must be at least 50 characters and non-placeholder when AUTH_MODE=local",
|
||||||
|
):
|
||||||
|
Settings(
|
||||||
|
_env_file=None,
|
||||||
|
auth_mode=AuthMode.LOCAL,
|
||||||
|
local_auth_token="",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_mode_requires_minimum_length() -> None:
|
||||||
|
with pytest.raises(
|
||||||
|
ValidationError,
|
||||||
|
match="LOCAL_AUTH_TOKEN must be at least 50 characters and non-placeholder when AUTH_MODE=local",
|
||||||
|
):
|
||||||
|
Settings(
|
||||||
|
_env_file=None,
|
||||||
|
auth_mode=AuthMode.LOCAL,
|
||||||
|
local_auth_token="x" * 49,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_mode_rejects_placeholder_token() -> None:
|
||||||
|
with pytest.raises(
|
||||||
|
ValidationError,
|
||||||
|
match="LOCAL_AUTH_TOKEN must be at least 50 characters and non-placeholder when AUTH_MODE=local",
|
||||||
|
):
|
||||||
|
Settings(
|
||||||
|
_env_file=None,
|
||||||
|
auth_mode=AuthMode.LOCAL,
|
||||||
|
local_auth_token="change-me",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_mode_accepts_real_token() -> None:
|
||||||
|
token = "a" * 50
|
||||||
|
settings = Settings(
|
||||||
|
_env_file=None,
|
||||||
|
auth_mode=AuthMode.LOCAL,
|
||||||
|
local_auth_token=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert settings.auth_mode == AuthMode.LOCAL
|
||||||
|
assert settings.local_auth_token == token
|
||||||
|
|
||||||
|
|
||||||
|
def test_clerk_mode_requires_secret_key() -> None:
|
||||||
|
with pytest.raises(
|
||||||
|
ValidationError,
|
||||||
|
match="CLERK_SECRET_KEY must be set and non-empty when AUTH_MODE=clerk",
|
||||||
|
):
|
||||||
|
Settings(
|
||||||
|
_env_file=None,
|
||||||
|
auth_mode=AuthMode.CLERK,
|
||||||
|
clerk_secret_key="",
|
||||||
|
)
|
||||||
100
backend/tests/test_local_auth_integration.py
Normal file
100
backend/tests/test_local_auth_integration.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# 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.auth_mode import AuthMode
|
||||||
|
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", AuthMode.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}
|
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-mission_control}
|
||||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000}
|
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000}
|
||||||
DB_AUTO_MIGRATE: ${DB_AUTO_MIGRATE:-true}
|
DB_AUTO_MIGRATE: ${DB_AUTO_MIGRATE:-true}
|
||||||
|
AUTH_MODE: ${AUTH_MODE}
|
||||||
|
LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -41,6 +43,7 @@ services:
|
|||||||
context: ./frontend
|
context: ./frontend
|
||||||
args:
|
args:
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000}
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000}
|
||||||
|
NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE}
|
||||||
# Optional, user-managed env file.
|
# Optional, user-managed env file.
|
||||||
# IMPORTANT: do NOT load `.env.example` here because it contains non-empty
|
# IMPORTANT: do NOT load `.env.example` here because it contains non-empty
|
||||||
# placeholder Clerk keys, which can accidentally flip Clerk "on".
|
# placeholder Clerk keys, which can accidentally flip Clerk "on".
|
||||||
@@ -49,6 +52,7 @@ services:
|
|||||||
required: false
|
required: false
|
||||||
environment:
|
environment:
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000}
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000}
|
||||||
|
NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
96
docs/00-style-guide.md
Normal file
96
docs/00-style-guide.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Documentation style guide
|
||||||
|
|
||||||
|
This repository aims for a NetBox-like style: clear, technical, and written for working engineers.
|
||||||
|
|
||||||
|
## Voice and tone
|
||||||
|
|
||||||
|
- **Direct and technical.** Prefer short sentences and specific nouns.
|
||||||
|
- **Narrative flow.** Describe how the system behaves, not how the doc was produced.
|
||||||
|
- **Calm, professional tone.** Avoid hype.
|
||||||
|
- **Assume competence, not context.** Define repo-specific terms once, then reuse them.
|
||||||
|
|
||||||
|
## Page structure (default)
|
||||||
|
|
||||||
|
Use a consistent, scan-friendly layout.
|
||||||
|
|
||||||
|
1. **Title**
|
||||||
|
2. **1–3 sentence intro**
|
||||||
|
- What this page covers and who it’s for.
|
||||||
|
3. **Deep dives / Related docs** (optional but common)
|
||||||
|
- Links to more detailed pages.
|
||||||
|
4. **Main content**
|
||||||
|
- Prefer sections that match user intent: “Quickstart”, “How it works”, “Configuration”, “Common workflows”, “Troubleshooting”.
|
||||||
|
5. **Next steps** (optional)
|
||||||
|
- Where to go next.
|
||||||
|
|
||||||
|
## Headings and conventions
|
||||||
|
|
||||||
|
- Prefer **verb-led** headings when describing procedures: “Run migrations”, “Regenerate the client”.
|
||||||
|
- Prefer **intent-led** headings when describing concepts: “How requests flow”, “Auth model”.
|
||||||
|
- Use numbered steps when order matters.
|
||||||
|
- Keep headings short; avoid long parentheticals.
|
||||||
|
|
||||||
|
## Cross-linking
|
||||||
|
|
||||||
|
- Treat the numbered IA pages in `docs/` as **entrypoints**.
|
||||||
|
- Link to deep dives instead of duplicating content.
|
||||||
|
- Use readable link text:
|
||||||
|
- Good: “Deployment guide” → `docs/deployment/README.md`
|
||||||
|
- Avoid: ``docs/deployment/README.md``
|
||||||
|
|
||||||
|
## Link formatting rules
|
||||||
|
|
||||||
|
- Use markdown links: `[Deployment guide](deployment/README.md)`.
|
||||||
|
- Use relative paths that work in GitHub and typical markdown renderers.
|
||||||
|
- Keep code formatting for:
|
||||||
|
- commands (`make check`)
|
||||||
|
- environment variables (`NEXT_PUBLIC_API_URL`)
|
||||||
|
- literal file paths when you mean “this exact file on disk” (not as a navigational link)
|
||||||
|
|
||||||
|
## Avoided phrases (and what to use instead)
|
||||||
|
|
||||||
|
Avoid doc-meta language:
|
||||||
|
|
||||||
|
- Avoid: “evidence basis”, “evidence anchors”, “this page is intentionally…”
|
||||||
|
- Prefer:
|
||||||
|
- “Source of truth: …” (only when it matters)
|
||||||
|
- “See also: …”
|
||||||
|
- Just link the file or section.
|
||||||
|
|
||||||
|
Avoid hedging:
|
||||||
|
|
||||||
|
- Avoid: “likely”, “probably”, “should” (unless it’s a policy decision)
|
||||||
|
- Prefer: state what the code does, and point to the file.
|
||||||
|
|
||||||
|
## Preferred patterns
|
||||||
|
|
||||||
|
- **Start here** blocks for role-based entry.
|
||||||
|
- **Common workflows** sections with copy/paste commands.
|
||||||
|
- **Troubleshooting** sections with symptoms → checks → fixes.
|
||||||
|
- **Footguns** called out explicitly when they can cause outages or confusing behavior.
|
||||||
|
|
||||||
|
## Example rewrites
|
||||||
|
|
||||||
|
### Example 1: remove doc-meta “evidence” language
|
||||||
|
|
||||||
|
Before:
|
||||||
|
> Evidence basis: consolidated from repo root `README.md`, `.github/workflows/ci.yml`, `Makefile`.
|
||||||
|
|
||||||
|
After:
|
||||||
|
> This page describes the development workflow that matches CI: setup, checks, and common local loops.
|
||||||
|
|
||||||
|
### Example 2: prefer readable links over code-formatted paths
|
||||||
|
|
||||||
|
Before:
|
||||||
|
- See `docs/deployment/README.md` for deployment.
|
||||||
|
|
||||||
|
After:
|
||||||
|
- See the [Deployment guide](deployment/README.md).
|
||||||
|
|
||||||
|
### Example 3: replace “first pass” filler with a clear scope boundary
|
||||||
|
|
||||||
|
Before:
|
||||||
|
- Non-goals (first pass)
|
||||||
|
|
||||||
|
After:
|
||||||
|
- Out of scope
|
||||||
@@ -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.
|
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
|
## Components
|
||||||
|
|
||||||
@@ -48,14 +48,17 @@ Common UI-driven data shapes:
|
|||||||
- “boards/tasks” views → board/task CRUD + streams.
|
- “boards/tasks” views → board/task CRUD + streams.
|
||||||
- “activity feed” → activity/events endpoints.
|
- “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.
|
- **Frontend**:
|
||||||
- Gating/wrappers: `frontend/src/auth/clerkKey.ts`, `frontend/src/auth/clerk.tsx`.
|
- `local`: token entry + token storage (`frontend/src/components/organisms/LocalAuthLogin.tsx`, `frontend/src/auth/localAuth.ts`).
|
||||||
- **Frontend → backend**: API calls attach `Authorization: Bearer <token>` when available.
|
- `clerk`: Clerk wrappers/hooks (`frontend/src/auth/clerk.tsx`).
|
||||||
- Token injection: `frontend/src/api/mutator.ts` (uses `window.Clerk.session.getToken()`).
|
- **Frontend → backend**:
|
||||||
- **Backend**: validates inbound auth and resolves a user context.
|
- API calls attach `Authorization: Bearer <token>` from local mode token or Clerk session token (`frontend/src/api/mutator.ts`).
|
||||||
- Implementation: `backend/app/core/auth.py` (uses `clerk_backend_api` SDK with `CLERK_SECRET_KEY`).
|
- **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/*`)
|
### 3) Agent automation surface (`/api/v1/agent/*`)
|
||||||
|
|
||||||
|
|||||||
@@ -68,12 +68,12 @@ Clerk:
|
|||||||
|
|
||||||
Template: `frontend/.env.example`.
|
Template: `frontend/.env.example`.
|
||||||
|
|
||||||
- `NEXT_PUBLIC_API_URL` (required)
|
| Variable | Required? | Purpose | Default / example | Footguns |
|
||||||
|
|---|---:|---|---|---|
|
||||||
Clerk:
|
| `NEXT_PUBLIC_API_URL` | **yes** | Backend base URL used by the browser | `http://localhost:8000` | Must be browser-reachable |
|
||||||
- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`
|
| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | **yes** | Enables Clerk in the frontend | (none) | Must be a real publishable key |
|
||||||
- `CLERK_SECRET_KEY`
|
| `NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL` | optional | Fallback redirect | `/boards` | — |
|
||||||
- redirect URLs (`NEXT_PUBLIC_CLERK_*`)
|
| `NEXT_PUBLIC_CLERK_AFTER_SIGN_OUT_URL` | optional | Post-logout redirect | `/` | — |
|
||||||
|
|
||||||
## Minimal dev configuration
|
## Minimal dev configuration
|
||||||
|
|
||||||
@@ -103,6 +103,11 @@ Evidence: `backend/app/main.py`, `backend/app/core/config.py`.
|
|||||||
- `CORS_ORIGINS` is a comma-separated list.
|
- `CORS_ORIGINS` is a comma-separated list.
|
||||||
- It must include the frontend origin (e.g. `http://localhost:3000`) or browser requests will fail.
|
- It must include the frontend origin (e.g. `http://localhost:3000`) or browser requests will fail.
|
||||||
|
|
||||||
|
## Common footguns
|
||||||
|
|
||||||
|
- **Frontend env template vs runtime env**: `frontend/.env.example` is a template and `compose.yml` intentionally does **not** load it at runtime. Use user-managed `frontend/.env` (for Compose) or `frontend/.env.local` (for Next dev).
|
||||||
|
- **`NEXT_PUBLIC_API_URL` reachability**: must work from the browser’s network context (host), not only from within the Docker network.
|
||||||
|
|
||||||
## Troubleshooting config issues
|
## Troubleshooting config issues
|
||||||
|
|
||||||
- UI loads but API calls fail / Activity feed blank → `NEXT_PUBLIC_API_URL` is missing/incorrect.
|
- UI loads but API calls fail / Activity feed blank → `NEXT_PUBLIC_API_URL` is missing/incorrect.
|
||||||
|
|||||||
@@ -44,21 +44,56 @@ Evidence: `backend/app/main.py` includes routers from `backend/app/api/*`.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `activity.py` | `/activity` | Activity listing and task-comment feed endpoints. |
|
| `activity.py` | `/activity` | Activity listing and task-comment feed endpoints. |
|
||||||
| `agent.py` | `/agent` | Agent-scoped API routes for board operations and gateway coordination. |
|
| `agent.py` | `/agent` | Agent-scoped API routes for board operations and gateway coordination. |
|
||||||
| `agents.py` | `/agents` | Agent lifecycle and streaming endpoints. |
|
| `agents.py` | `/agents` | Thin API wrappers for async agent lifecycle operations. |
|
||||||
| `approvals.py` | `/boards/{board_id}/approvals` | Approval list/create/update + streaming. |
|
| `approvals.py` | `/boards/{board_id}/approvals` | Approval listing, streaming, creation, and update endpoints. |
|
||||||
| `auth.py` | `/auth` | Auth bootstrap endpoints. |
|
| `auth.py` | `/auth` | Authentication bootstrap endpoints for the Mission Control API. |
|
||||||
| `board_group_memory.py` | `/board-groups/{group_id}/memory` and `/boards/{board_id}/group-memory` | Board-group memory CRUD + streaming. |
|
| `board_group_memory.py` | `/board-groups/{group_id}/memory` and `/boards/{board_id}/group-memory` | Board-group memory CRUD and streaming endpoints. |
|
||||||
| `board_groups.py` | `/board-groups` | Board group CRUD + snapshot + heartbeat apply. |
|
| `board_groups.py` | `/board-groups` | Board group CRUD, snapshot, and heartbeat endpoints. |
|
||||||
| `board_memory.py` | `/boards/{board_id}/memory` | Board memory CRUD + streaming. |
|
| `board_memory.py` | `/boards/{board_id}/memory` | Board memory CRUD and streaming endpoints. |
|
||||||
| `board_onboarding.py` | `/boards/{board_id}/onboarding` | Onboarding flows (user+agent). |
|
| `board_onboarding.py` | `/boards/{board_id}/onboarding` | Board onboarding endpoints for user/agent collaboration. |
|
||||||
| `boards.py` | `/boards` | Board CRUD + snapshots. |
|
| `boards.py` | `/boards` | Board CRUD and snapshot endpoints. |
|
||||||
| `gateway.py` | `/gateways` | Gateway session inspection APIs (org admin). |
|
| `gateway.py` | `/gateways` | Thin gateway session-inspection API wrappers. |
|
||||||
| `gateways.py` | `/gateways` | Gateway CRUD + templates sync (org admin). |
|
| `gateways.py` | `/gateways` | Thin API wrappers for gateway CRUD and template synchronization. |
|
||||||
| `metrics.py` | `/metrics` | Dashboard metrics. |
|
| `metrics.py` | `/metrics` | Dashboard metric aggregation endpoints. |
|
||||||
| `organizations.py` | `/organizations` | Org + invites/membership flows. |
|
| `organizations.py` | `/organizations` | Organization management endpoints and membership/invite flows. |
|
||||||
| `souls_directory.py` | `/souls-directory` | Search/fetch souls directory entries. |
|
| `souls_directory.py` | `/souls-directory` | API routes for searching and fetching souls-directory markdown entries. |
|
||||||
| `tasks.py` | `/boards/{board_id}/tasks` | Task CRUD + comments + streaming. |
|
| `tasks.py` | `/boards/{board_id}/tasks` | Task API routes for listing, streaming, and mutating board tasks. |
|
||||||
| `users.py` | `/users` | User self-service profile endpoints. |
|
| `users.py` | `/users` | User self-service API endpoints for profile retrieval and updates. |
|
||||||
|
|
||||||
|
## Backend API layer notes (how modules are organized)
|
||||||
|
|
||||||
|
Evidence: `backend/app/main.py`, `backend/app/api/*`, `backend/app/api/deps.py`.
|
||||||
|
|
||||||
|
### Conventions
|
||||||
|
|
||||||
|
- Each file under `backend/app/api/*` typically declares an `APIRouter` (`router = APIRouter(...)`) and defines endpoints with decorators like `@router.get(...)`, `@router.post(...)`, etc.
|
||||||
|
- Board-scoped modules embed `{board_id}` in the prefix (e.g. `/boards/{board_id}/tasks`).
|
||||||
|
- Streaming endpoints usually expose **SSE** endpoints at `.../stream` (see `sse-starlette` usage).
|
||||||
|
|
||||||
|
### Where key behaviors live
|
||||||
|
|
||||||
|
- **Router wiring / base prefix**: `backend/app/main.py` mounts these routers under `/api/v1/*`.
|
||||||
|
- **Auth / access control** is mostly expressed through dependencies (see `backend/app/api/deps.py`):
|
||||||
|
- `require_admin_auth` — require an authenticated *admin user*.
|
||||||
|
- `require_admin_or_agent` — allow either an admin user or an authenticated agent.
|
||||||
|
- `get_board_for_actor_read` / `get_board_for_actor_write` — enforce board access for the calling actor.
|
||||||
|
- `require_org_member` / `require_org_admin` — enforce org membership/admin for user callers.
|
||||||
|
- **Agent-only surface**: `backend/app/api/agent.py` uses `get_agent_auth_context` (X-Agent-Token) and contains board/task/memory endpoints specifically for automation.
|
||||||
|
|
||||||
|
### Module-by-module map (prefix, key endpoints, and pointers)
|
||||||
|
|
||||||
|
This is a “where to look” index, not a full OpenAPI dump. For exact parameters and response shapes, see:
|
||||||
|
- route module file (`backend/app/api/<module>.py`)
|
||||||
|
- schemas (`backend/app/schemas/*`)
|
||||||
|
- models (`backend/app/models/*`)
|
||||||
|
- services (`backend/app/services/*`)
|
||||||
|
|
||||||
|
| Module | Prefix (under `/api/v1`) | Key endpoints (examples) | Main deps / auth | Pointers (schemas/models/services) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `activity.py` | `/activity` | `GET /activity` (events); `GET /activity/task-comments` + `/stream` | `require_admin_or_agent`, `require_org_member` | `app/models/activity_events.py`, `app/schemas/activity_events.py` |
|
||||||
|
| `agent.py` | `/agent` | agent automation surface: boards/tasks/memory + gateway coordination | `get_agent_auth_context` (X-Agent-Token) | `backend/app/core/agent_auth.py`, `backend/app/services/openclaw/*` |
|
||||||
|
| `agents.py` | `/agents` | agent lifecycle + SSE stream + heartbeat | org-admin gated for user callers; some endpoints allow agent access via deps | `app/schemas/agents.py`, `app/services/openclaw/provisioning_db.py` |
|
||||||
|
| `approvals.py` | `/boards/{board_id}/approvals` | list/create/update approvals + `/stream` | `require_admin_or_agent` + board access deps | `app/models/approvals.py`, `app/schemas/approvals.py` |
|
||||||
|
|
||||||
## Where authorization is enforced
|
## Where authorization is enforced
|
||||||
|
|
||||||
|
|||||||
137
docs/12-backend-core.md
Normal file
137
docs/12-backend-core.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# Backend core modules (auth/config/logging/errors)
|
||||||
|
|
||||||
|
> Evidence basis: repo https://github.com/abhi1693/openclaw-mission-control @ commit `c3490630a4503d9c8142aaa3abf542e0d00b5035`.
|
||||||
|
|
||||||
|
This page documents the backend “core” layer under `backend/app/core/*` plus the API dependency module `backend/app/api/deps.py`.
|
||||||
|
|
||||||
|
It’s written for maintainers who need to answer:
|
||||||
|
|
||||||
|
- “Where does configuration come from?”
|
||||||
|
- “How do user vs agent auth work?”
|
||||||
|
- “Where are authorization decisions enforced?”
|
||||||
|
- “What’s the error envelope / request-id behavior?”
|
||||||
|
- “How is logging structured and how do I get request-context in logs?”
|
||||||
|
|
||||||
|
## Start here (reading order)
|
||||||
|
|
||||||
|
1. `backend/app/core/config.py` — settings + env file loading
|
||||||
|
2. `backend/app/core/logging.py` — structured logging + request context
|
||||||
|
3. `backend/app/core/error_handling.py` — request-id middleware + exception envelope
|
||||||
|
4. `backend/app/core/auth.py` — Clerk/user auth resolution
|
||||||
|
5. `backend/app/core/agent_auth.py` — agent token auth resolution
|
||||||
|
6. `backend/app/api/deps.py` — how routes declare and enforce access
|
||||||
|
|
||||||
|
## Configuration: loading & precedence
|
||||||
|
|
||||||
|
**Primary file:** `backend/app/core/config.py`
|
||||||
|
|
||||||
|
Key facts:
|
||||||
|
- Uses `pydantic-settings` (`BaseSettings`) to load typed settings from environment.
|
||||||
|
- Env files are loaded regardless of current working directory:
|
||||||
|
- `backend/.env` (via `DEFAULT_ENV_FILE`)
|
||||||
|
- then `.env` (repo root) as an additional source
|
||||||
|
- See `Settings.model_config.env_file=[DEFAULT_ENV_FILE, ".env"]`.
|
||||||
|
- Unknown env vars are ignored (`extra="ignore"`).
|
||||||
|
|
||||||
|
Notable settings (security-sensitive in **bold**):
|
||||||
|
- `DATABASE_URL` / `database_url`
|
||||||
|
- `CORS_ORIGINS` / `cors_origins`
|
||||||
|
- `DB_AUTO_MIGRATE` / `db_auto_migrate`
|
||||||
|
- **`CLERK_SECRET_KEY` / `clerk_secret_key`** (must be non-empty; validator enforces it)
|
||||||
|
- `CLERK_API_URL`, `CLERK_VERIFY_IAT`, `CLERK_LEEWAY`
|
||||||
|
- logging knobs: `LOG_LEVEL`, `LOG_FORMAT`, `LOG_USE_UTC`, `REQUEST_LOG_SLOW_MS`, `REQUEST_LOG_INCLUDE_HEALTH`
|
||||||
|
|
||||||
|
### Deployment implication
|
||||||
|
|
||||||
|
- If a deployment accidentally starts the backend with an empty/placeholder `CLERK_SECRET_KEY`, the backend will fail settings validation at startup.
|
||||||
|
|
||||||
|
## Auth model split
|
||||||
|
|
||||||
|
The backend supports two top-level actor types:
|
||||||
|
|
||||||
|
- **User** (human UI / admin) — resolved from the `Authorization: Bearer <token>` header via Clerk.
|
||||||
|
- **Agent** (automation) — resolved from `X-Agent-Token: <token>` (and optionally `Authorization: Bearer <token>` for agent callers).
|
||||||
|
|
||||||
|
### User auth (Clerk) — `backend/app/core/auth.py`
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
- Uses the `clerk_backend_api` SDK to authenticate requests (`authenticate_request(...)`) using `CLERK_SECRET_KEY`.
|
||||||
|
- Resolves a `AuthContext` containing `actor_type="user"` and a `User` model instance.
|
||||||
|
- The module includes helpers to fetch user profile details from Clerk (`_fetch_clerk_profile`) and to delete a Clerk user (`delete_clerk_user`).
|
||||||
|
|
||||||
|
Security-sensitive notes:
|
||||||
|
- Treat `CLERK_SECRET_KEY` as a credential; never log it.
|
||||||
|
- This code calls Clerk API endpoints over the network (timeouts and error handling matter).
|
||||||
|
|
||||||
|
### Agent auth (token hash) — `backend/app/core/agent_auth.py`
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
- Requires a token header for protected agent endpoints:
|
||||||
|
- Primary header: `X-Agent-Token`
|
||||||
|
- Optional parsing: `Authorization: Bearer ...` (only in `get_agent_auth_context`, and only if `accept_authorization=True`)
|
||||||
|
- Validates token by comparing it against stored `agent_token_hash` values in the DB (`verify_agent_token`).
|
||||||
|
- “Touches” agent presence (`last_seen_at`, `status`) on authenticated requests.
|
||||||
|
- For safe methods (`GET/HEAD/OPTIONS`), it commits immediately so read-only polling still shows the agent as online.
|
||||||
|
|
||||||
|
Security-sensitive notes:
|
||||||
|
- Token verification iterates over agents with a token hash. If this grows large, consider indexing/lookup strategy.
|
||||||
|
- Never echo full tokens in logs; current code logs only a prefix on invalid tokens.
|
||||||
|
|
||||||
|
## Authorization enforcement: `backend/app/api/deps.py`
|
||||||
|
|
||||||
|
This module is the primary “policy wiring” for most routes.
|
||||||
|
|
||||||
|
Key concepts:
|
||||||
|
|
||||||
|
- `require_admin_auth(...)`
|
||||||
|
- Requires an authenticated *admin user*.
|
||||||
|
- `require_admin_or_agent(...)` → returns `ActorContext`
|
||||||
|
- Allows either:
|
||||||
|
- admin user (user auth via Clerk), or
|
||||||
|
- authenticated agent (agent auth via X-Agent-Token).
|
||||||
|
|
||||||
|
Board/task access patterns:
|
||||||
|
- `get_board_for_actor_read` / `get_board_for_actor_write`
|
||||||
|
- Enforces that the caller (user or agent) has the correct access to the board.
|
||||||
|
- Agent access is restricted if the agent is bound to a specific board (`agent.board_id`).
|
||||||
|
- `get_task_or_404`
|
||||||
|
- Loads a task and ensures it belongs to the requested board.
|
||||||
|
|
||||||
|
Org access patterns (user callers):
|
||||||
|
- `require_org_member` and `require_org_admin`
|
||||||
|
- Resolve/require active org membership.
|
||||||
|
- Provide an `OrganizationContext` with `organization` + `member`.
|
||||||
|
|
||||||
|
Maintainer tip:
|
||||||
|
- When debugging a “why is this 403/401?”, start by checking the route’s dependency stack (in the route module) and trace through the relevant dependency in `deps.py`.
|
||||||
|
|
||||||
|
## Logging: structure + request context
|
||||||
|
|
||||||
|
**Primary file:** `backend/app/core/logging.py`
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
- Defines a custom TRACE level (`TRACE_LEVEL = 5`).
|
||||||
|
- Uses `contextvars` to carry `request_id`, `method`, and `path` across async tasks.
|
||||||
|
- `AppLogFilter` injects `app`, `version`, and request context into each log record.
|
||||||
|
- Supports JSON output (`JsonFormatter`) and key=value (`KeyValueFormatter`) formats.
|
||||||
|
|
||||||
|
Where request context gets set:
|
||||||
|
- `backend/app/core/error_handling.py` middleware calls:
|
||||||
|
- `set_request_id(...)`
|
||||||
|
- `set_request_route_context(method, path)`
|
||||||
|
|
||||||
|
## Error envelope + request-id
|
||||||
|
|
||||||
|
**Primary file:** `backend/app/core/error_handling.py`
|
||||||
|
|
||||||
|
Key behaviors:
|
||||||
|
- Installs a `RequestIdMiddleware` (ASGI) that:
|
||||||
|
- Accepts client-provided `X-Request-Id` or generates one.
|
||||||
|
- Adds `X-Request-Id` to the response.
|
||||||
|
- Emits structured “http.request.*” logs, including “slow request” warnings.
|
||||||
|
- Error responses include `request_id` when available:
|
||||||
|
- Validation errors (`422`) return `{detail: <errors>, request_id: ...}`.
|
||||||
|
- Other HTTP errors are wrapped similarly.
|
||||||
|
|
||||||
|
Maintainer tip:
|
||||||
|
- When debugging incidents, ask for the `X-Request-Id` from the client and use it to locate backend logs quickly.
|
||||||
@@ -10,6 +10,9 @@ This folder is the canonical documentation set for Mission Control.
|
|||||||
|
|
||||||
## Table of contents (IA)
|
## Table of contents (IA)
|
||||||
|
|
||||||
|
- [Style guide](00-style-guide.md)
|
||||||
|
|
||||||
|
|
||||||
1. [Overview](01-overview.md)
|
1. [Overview](01-overview.md)
|
||||||
2. [Quickstart](02-quickstart.md)
|
2. [Quickstart](02-quickstart.md)
|
||||||
3. [Development](03-development.md)
|
3. [Development](03-development.md)
|
||||||
@@ -17,6 +20,7 @@ This folder is the canonical documentation set for Mission Control.
|
|||||||
5. [Architecture](05-architecture.md)
|
5. [Architecture](05-architecture.md)
|
||||||
6. [Configuration](06-configuration.md)
|
6. [Configuration](06-configuration.md)
|
||||||
7. [API reference](07-api-reference.md)
|
7. [API reference](07-api-reference.md)
|
||||||
|
- [Frontend API + auth modules](frontend-api-auth.md)
|
||||||
8. [Agents & skills](08-agents-and-skills.md)
|
8. [Agents & skills](08-agents-and-skills.md)
|
||||||
9. [Ops / runbooks](09-ops-runbooks.md)
|
9. [Ops / runbooks](09-ops-runbooks.md)
|
||||||
10. [Troubleshooting](10-troubleshooting.md)
|
10. [Troubleshooting](10-troubleshooting.md)
|
||||||
|
|||||||
@@ -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.
|
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:
|
At a high level:
|
||||||
- The **frontend** is a Next.js app used by humans.
|
- 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)
|
- Routes/pages: `frontend/src/app/*` (Next.js App Router)
|
||||||
- API utilities: `frontend/src/lib/*` and `frontend/src/api/*`
|
- API utilities: `frontend/src/lib/*` and `frontend/src/api/*`
|
||||||
|
|
||||||
**Auth (Clerk, required)**
|
**Auth (`local` or Clerk)**
|
||||||
- Clerk is required for real deployments and currently required by backend config (see `backend/app/core/config.py`).
|
- `local` mode authenticates a shared bearer token (`LOCAL_AUTH_TOKEN`) and resolves a local user context.
|
||||||
- Frontend uses Clerk when keys are configured; see `frontend/src/auth/clerkKey.ts` and `frontend/src/auth/clerk.tsx`.
|
- `clerk` mode verifies Clerk JWTs using `CLERK_SECRET_KEY`.
|
||||||
- Backend authenticates requests using the Clerk SDK and `CLERK_SECRET_KEY`; see `backend/app/core/auth.py`.
|
- 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)
|
### Backend (FastAPI)
|
||||||
@@ -64,9 +65,13 @@ Mission Control can call into an OpenClaw Gateway over WebSockets.
|
|||||||
2. Frontend calls backend endpoints under `/api/v1/*`.
|
2. Frontend calls backend endpoints under `/api/v1/*`.
|
||||||
3. Backend reads/writes Postgres.
|
3. Backend reads/writes Postgres.
|
||||||
|
|
||||||
### Auth (Clerk — required)
|
### Auth (`local` or Clerk)
|
||||||
- **Frontend** uses Clerk when keys are configured (see `frontend/src/auth/*`).
|
- **Frontend**:
|
||||||
- **Backend** authenticates requests using the Clerk SDK and `CLERK_SECRET_KEY` (see `backend/app/core/auth.py`).
|
- `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)
|
### Agent access (X-Agent-Token)
|
||||||
Automation/agents can use the “agent” API surface:
|
Automation/agents can use the “agent” API surface:
|
||||||
- Endpoints under `/api/v1/agent/*` (router: `backend/app/api/agent.py`).
|
- Endpoints under `/api/v1/agent/*` (router: `backend/app/api/agent.py`).
|
||||||
@@ -92,7 +97,7 @@ Backend:
|
|||||||
Frontend:
|
Frontend:
|
||||||
- `frontend/src/app/` — Next.js routes
|
- `frontend/src/app/` — Next.js routes
|
||||||
- `frontend/src/components/` — UI components
|
- `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
|
- `frontend/src/lib/` — utilities + API base
|
||||||
|
|
||||||
## Where to start reading code
|
## Where to start reading code
|
||||||
@@ -106,7 +111,7 @@ Backend:
|
|||||||
Frontend:
|
Frontend:
|
||||||
1. `frontend/src/app/*` — main UI routes
|
1. `frontend/src/app/*` — main UI routes
|
||||||
2. `frontend/src/lib/api-base.ts` — backend calls
|
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
|
## 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)
|
- Self-host (Docker Compose): see repo root README: [Quick start (self-host with Docker Compose)](../../README.md#quick-start-self-host-with-docker-compose)
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ When running Compose, you get:
|
|||||||
- Health check: `GET /healthz`
|
- Health check: `GET /healthz`
|
||||||
- **Frontend UI** (Next.js) on `http://localhost:${FRONTEND_PORT:-3000}`
|
- **Frontend UI** (Next.js) on `http://localhost:${FRONTEND_PORT:-3000}`
|
||||||
|
|
||||||
Auth (Clerk) is **required** right now. You must configure Clerk keys for the frontend and backend (`CLERK_SECRET_KEY`).
|
Auth is configurable per deployment:
|
||||||
|
- `AUTH_MODE=local` (self-host default; shared bearer token)
|
||||||
|
- `AUTH_MODE=clerk` (Clerk JWT auth; backend requires `CLERK_SECRET_KEY`)
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -30,6 +32,9 @@ From repo root:
|
|||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
|
# REQUIRED for local mode:
|
||||||
|
# set LOCAL_AUTH_TOKEN in .env to a non-placeholder value with at least 50 characters.
|
||||||
|
|
||||||
docker compose -f compose.yml --env-file .env up -d --build
|
docker compose -f compose.yml --env-file .env up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -86,7 +91,7 @@ These persist across `docker compose down`.
|
|||||||
### Root `.env` (Compose)
|
### Root `.env` (Compose)
|
||||||
|
|
||||||
- Copy the template: `cp .env.example .env`
|
- Copy the template: `cp .env.example .env`
|
||||||
- Edit values as needed (ports, Clerk URLs/keys, etc.)
|
- Edit values as needed (ports, auth mode, tokens, API URL, etc.)
|
||||||
|
|
||||||
Compose is invoked with:
|
Compose is invoked with:
|
||||||
|
|
||||||
@@ -110,49 +115,57 @@ Instead, it supports an optional user-managed env file:
|
|||||||
|
|
||||||
If present, Compose will load it.
|
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
|
```env
|
||||||
# Frontend → Backend
|
AUTH_MODE=local
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
LOCAL_AUTH_TOKEN=replace-with-random-token-at-least-50-characters
|
||||||
|
|
||||||
# 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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
|
```env
|
||||||
CLERK_SECRET_KEY=sk_test_your_real_key
|
CLERK_SECRET_KEY=sk_test_your_real_key
|
||||||
|
|
||||||
# Optional tuning
|
|
||||||
CLERK_API_URL=https://api.clerk.com
|
CLERK_API_URL=https://api.clerk.com
|
||||||
CLERK_VERIFY_IAT=true
|
CLERK_VERIFY_IAT=true
|
||||||
CLERK_LEEWAY=10.0
|
CLERK_LEEWAY=10.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Then either:
|
Create `frontend/.env` with at least:
|
||||||
1) update `compose.yml` to load `backend/.env` (recommended), or
|
|
||||||
2) pass the values via `services.backend.environment`.
|
|
||||||
|
|
||||||
**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
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security:** treat `LOCAL_AUTH_TOKEN` and `CLERK_SECRET_KEY` like passwords. Do not commit them.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
109
docs/frontend-api-auth.md
Normal file
109
docs/frontend-api-auth.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Frontend API client and auth integration
|
||||||
|
|
||||||
|
This page documents the frontend integration points you’ll touch when changing how the UI talks to the backend or how auth is applied.
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
|
||||||
|
- [Architecture](05-architecture.md)
|
||||||
|
- [Configuration](06-configuration.md)
|
||||||
|
- [API reference](07-api-reference.md)
|
||||||
|
|
||||||
|
## API base URL
|
||||||
|
|
||||||
|
The frontend uses `NEXT_PUBLIC_API_URL` as the single source of truth for where to send API requests.
|
||||||
|
|
||||||
|
- Code: `frontend/src/lib/api-base.ts`
|
||||||
|
- Behavior:
|
||||||
|
- reads `process.env.NEXT_PUBLIC_API_URL`
|
||||||
|
- normalizes by trimming trailing slashes
|
||||||
|
- throws early if missing/invalid
|
||||||
|
|
||||||
|
In Docker Compose, `compose.yml` sets `NEXT_PUBLIC_API_URL` both:
|
||||||
|
- as a **build arg** (for `next build`), and
|
||||||
|
- as a **runtime env var**.
|
||||||
|
|
||||||
|
## API client layout
|
||||||
|
|
||||||
|
### Generated client
|
||||||
|
|
||||||
|
- Location: `frontend/src/api/generated/*`
|
||||||
|
- Generator: **Orval**
|
||||||
|
- Config: `frontend/orval.config.ts`
|
||||||
|
- Script: `cd frontend && npm run api:gen`
|
||||||
|
- Convenience target: `make api-gen`
|
||||||
|
|
||||||
|
By default, Orval reads the backend OpenAPI schema from:
|
||||||
|
- `ORVAL_INPUT` (if set), otherwise
|
||||||
|
- `http://127.0.0.1:8000/openapi.json`
|
||||||
|
|
||||||
|
Output details (from `orval.config.ts`):
|
||||||
|
- Mode: `tags-split`
|
||||||
|
- Target index: `frontend/src/api/generated/index.ts`
|
||||||
|
- Schemas: `frontend/src/api/generated/model`
|
||||||
|
- Client: `react-query`
|
||||||
|
- All requests go through the custom mutator below.
|
||||||
|
|
||||||
|
### Custom fetch / mutator
|
||||||
|
|
||||||
|
All generated requests go through:
|
||||||
|
|
||||||
|
- Code: `frontend/src/api/mutator.ts`
|
||||||
|
- What it does:
|
||||||
|
- resolves `NEXT_PUBLIC_API_URL` and builds the full request URL
|
||||||
|
- sets `Content-Type: application/json` when there’s a body
|
||||||
|
- injects `Authorization: Bearer <token>` when a Clerk session token is available
|
||||||
|
- converts non-2xx responses into a typed `ApiError` (status + parsed response)
|
||||||
|
|
||||||
|
## Auth enablement and token injection
|
||||||
|
|
||||||
|
### Clerk enablement (publishable key gating)
|
||||||
|
|
||||||
|
Clerk is enabled in the frontend only when `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` looks valid.
|
||||||
|
|
||||||
|
- Gating helper (dependency-free): `frontend/src/auth/clerkKey.ts`
|
||||||
|
- UI-safe wrappers/hooks: `frontend/src/auth/clerk.tsx`
|
||||||
|
- provides `SignedIn`, `SignedOut`, `SignInButton`, `SignOutButton`, `useUser`, and `useAuth`
|
||||||
|
- returns safe fallbacks when Clerk is disabled (to allow secretless builds/prerender)
|
||||||
|
|
||||||
|
### Token injection
|
||||||
|
|
||||||
|
When the UI makes an API request, the mutator attempts to read a token from the Clerk session:
|
||||||
|
|
||||||
|
- Code: `frontend/src/api/mutator.ts` (`resolveClerkToken()`)
|
||||||
|
- If a token is available, the request includes:
|
||||||
|
- `Authorization: Bearer <token>`
|
||||||
|
|
||||||
|
### Route protection (middleware)
|
||||||
|
|
||||||
|
Request-time route protection is implemented via Next.js middleware:
|
||||||
|
|
||||||
|
- Code: `frontend/src/proxy.ts`
|
||||||
|
- Behavior:
|
||||||
|
- when Clerk is enabled: uses `clerkMiddleware()` to enforce auth on non-public routes
|
||||||
|
- when Clerk is disabled: passes all requests through
|
||||||
|
|
||||||
|
## Common workflows
|
||||||
|
|
||||||
|
### Update the backend API and regenerate the client
|
||||||
|
|
||||||
|
1. Run the backend so OpenAPI is available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from repo root
|
||||||
|
cp backend/.env.example backend/.env
|
||||||
|
make backend-migrate
|
||||||
|
cd backend && uv run uvicorn app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Regenerate the client:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from repo root
|
||||||
|
make api-gen
|
||||||
|
|
||||||
|
# or from frontend/
|
||||||
|
ORVAL_INPUT=http://127.0.0.1:8000/openapi.json npm run api:gen
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Review diffs under `frontend/src/api/generated/*`.
|
||||||
|
|
||||||
@@ -59,8 +59,10 @@ Recommended approach:
|
|||||||
|
|
||||||
Secrets guidelines:
|
Secrets guidelines:
|
||||||
|
|
||||||
- **Clerk auth is required for now**: you must configure Clerk keys/JWKS for the app to work.
|
- Choose auth mode explicitly:
|
||||||
- Never commit Clerk secret key.
|
- `AUTH_MODE=local`: set `LOCAL_AUTH_TOKEN` to a random value with at least 50 characters
|
||||||
|
- `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
|
- Prefer passing secrets as environment variables from the host (or use Docker secrets if you later
|
||||||
migrate to Swarm/K8s).
|
migrate to Swarm/K8s).
|
||||||
- Rotate secrets if they ever hit logs.
|
- 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
|
cd mission-control
|
||||||
|
|
||||||
cp .env.example .env
|
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
|
docker compose -f compose.yml --env-file .env up -d --build
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
# Must be reachable from the browser (host).
|
# Must be reachable from the browser (host).
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||||
|
|
||||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY
|
# Auth mode: clerk or local.
|
||||||
CLERK_SECRET_KEY=YOUR_SECRET_KEY
|
# - clerk: Clerk sign-in flow
|
||||||
NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL=/boards
|
# - local: shared bearer token entered in UI
|
||||||
NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL=/boards
|
NEXT_PUBLIC_AUTH_MODE=local
|
||||||
|
|
||||||
|
# Clerk auth (used when NEXT_PUBLIC_AUTH_MODE=clerk)
|
||||||
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
||||||
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/boards
|
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/boards
|
||||||
NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/boards
|
|
||||||
NEXT_PUBLIC_CLERK_AFTER_SIGN_OUT_URL=/
|
NEXT_PUBLIC_CLERK_AFTER_SIGN_OUT_URL=/
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ COPY . ./
|
|||||||
# Allows configuring the API URL at build time.
|
# Allows configuring the API URL at build time.
|
||||||
ARG NEXT_PUBLIC_API_URL=http://localhost:8000
|
ARG NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
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
|
RUN npm run build
|
||||||
|
|
||||||
@@ -22,10 +24,12 @@ FROM node:20-alpine AS runner
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
ARG NEXT_PUBLIC_AUTH_MODE
|
||||||
|
|
||||||
# If provided at runtime, Next will expose NEXT_PUBLIC_* to the browser as well
|
# If provided at runtime, Next will expose NEXT_PUBLIC_* to the browser as well
|
||||||
# (but note some values may be baked at build time).
|
# (but note some values may be baked at build time).
|
||||||
ENV NEXT_PUBLIC_API_URL=http://localhost:8000
|
ENV NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||||
|
ENV NEXT_PUBLIC_AUTH_MODE=${NEXT_PUBLIC_AUTH_MODE}
|
||||||
|
|
||||||
COPY --from=builder /app/.next ./.next
|
COPY --from=builder /app/.next ./.next
|
||||||
# `public/` is optional in Next.js apps; repo may not have it.
|
# `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`).
|
- Talks to the Mission Control **backend** over HTTP (typically `http://localhost:8000`).
|
||||||
- Uses **React Query** for data fetching.
|
- 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
|
## Prerequisites
|
||||||
|
|
||||||
@@ -53,27 +55,23 @@ Example:
|
|||||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
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.
|
- `local` (default for self-host)
|
||||||
Implementation detail: we gate on a conservative regex (`pk_test_...` / `pk_live_...`) in `src/auth/clerkKey.ts`.
|
- `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`
|
- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`
|
||||||
- If **unset/blank/placeholder**, Clerk is treated as **disabled**.
|
- optional `NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL`
|
||||||
- `CLERK_SECRET_KEY`
|
- optional `NEXT_PUBLIC_CLERK_AFTER_SIGN_OUT_URL`
|
||||||
- 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”.
|
|
||||||
|
|
||||||
## How the frontend talks to the backend
|
## 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:
|
It will:
|
||||||
|
|
||||||
- set `Content-Type: application/json` when there is a body and you didn’t specify a content type
|
- 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
|
- parse errors into an `ApiError` with status + parsed response body
|
||||||
|
|
||||||
## Common commands
|
## Common commands
|
||||||
@@ -149,11 +147,11 @@ cp .env.example .env.local
|
|||||||
- Confirm `NEXT_PUBLIC_API_URL` points to the correct host/port.
|
- Confirm `NEXT_PUBLIC_API_URL` points to the correct host/port.
|
||||||
- If accessing from another device (LAN), use a reachable backend URL (not `localhost`).
|
- 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.
|
- Ensure `NEXT_PUBLIC_AUTH_MODE` matches backend `AUTH_MODE`.
|
||||||
|
- For local mode, set `NEXT_PUBLIC_AUTH_MODE=local`.
|
||||||
- Remove/blank `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` in your `.env.local` to force Clerk off.
|
- For Clerk mode, set `NEXT_PUBLIC_AUTH_MODE=clerk` and a real Clerk publishable key.
|
||||||
|
|
||||||
### Dev server blocked by origin restrictions
|
### Dev server blocked by origin restrictions
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,18 @@ describe("/activity feed", () => {
|
|||||||
const apiBase = "**/api/v1";
|
const apiBase = "**/api/v1";
|
||||||
const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com";
|
const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com";
|
||||||
|
|
||||||
|
const originalDefaultCommandTimeout = Cypress.config("defaultCommandTimeout");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clerk's Cypress helpers perform async work inside `cy.then()`.
|
||||||
|
// CI can be slow enough that the default 4s command timeout flakes.
|
||||||
|
Cypress.config("defaultCommandTimeout", 20_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Cypress.config("defaultCommandTimeout", originalDefaultCommandTimeout);
|
||||||
|
});
|
||||||
|
|
||||||
function stubStreamEmpty() {
|
function stubStreamEmpty() {
|
||||||
cy.intercept(
|
cy.intercept(
|
||||||
"GET",
|
"GET",
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth";
|
||||||
|
|
||||||
type ClerkSession = {
|
type ClerkSession = {
|
||||||
getToken: () => Promise<string>;
|
getToken: () => Promise<string>;
|
||||||
};
|
};
|
||||||
@@ -48,6 +50,12 @@ export const customFetch = async <T>(
|
|||||||
if (hasBody && !headers.has("Content-Type")) {
|
if (hasBody && !headers.has("Content-Type")) {
|
||||||
headers.set("Content-Type", "application/json");
|
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")) {
|
if (!headers.has("Authorization")) {
|
||||||
const token = await resolveClerkToken();
|
const token = await resolveClerkToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
|
|||||||
@@ -16,21 +16,33 @@ import {
|
|||||||
} from "@clerk/nextjs";
|
} from "@clerk/nextjs";
|
||||||
|
|
||||||
import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey";
|
import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey";
|
||||||
|
import { getLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth";
|
||||||
|
|
||||||
|
function hasLocalAuthToken(): boolean {
|
||||||
|
return Boolean(getLocalAuthToken());
|
||||||
|
}
|
||||||
|
|
||||||
export function isClerkEnabled(): boolean {
|
export function isClerkEnabled(): boolean {
|
||||||
// IMPORTANT: keep this in sync with AuthProvider; otherwise components like
|
// IMPORTANT: keep this in sync with AuthProvider; otherwise components like
|
||||||
// <SignedOut/> may render without a <ClerkProvider/> and crash during prerender.
|
// <SignedOut/> may render without a <ClerkProvider/> and crash during prerender.
|
||||||
|
if (isLocalAuthMode()) return false;
|
||||||
return isLikelyValidClerkPublishableKey(
|
return isLikelyValidClerkPublishableKey(
|
||||||
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SignedIn(props: { children: ReactNode }) {
|
export function SignedIn(props: { children: ReactNode }) {
|
||||||
|
if (isLocalAuthMode()) {
|
||||||
|
return hasLocalAuthToken() ? <>{props.children}</> : null;
|
||||||
|
}
|
||||||
if (!isClerkEnabled()) return null;
|
if (!isClerkEnabled()) return null;
|
||||||
return <ClerkSignedIn>{props.children}</ClerkSignedIn>;
|
return <ClerkSignedIn>{props.children}</ClerkSignedIn>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SignedOut(props: { children: ReactNode }) {
|
export function SignedOut(props: { children: ReactNode }) {
|
||||||
|
if (isLocalAuthMode()) {
|
||||||
|
return hasLocalAuthToken() ? null : <>{props.children}</>;
|
||||||
|
}
|
||||||
if (!isClerkEnabled()) return <>{props.children}</>;
|
if (!isClerkEnabled()) return <>{props.children}</>;
|
||||||
return <ClerkSignedOut>{props.children}</ClerkSignedOut>;
|
return <ClerkSignedOut>{props.children}</ClerkSignedOut>;
|
||||||
}
|
}
|
||||||
@@ -49,6 +61,13 @@ export function SignOutButton(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useUser() {
|
export function useUser() {
|
||||||
|
if (isLocalAuthMode()) {
|
||||||
|
return {
|
||||||
|
isLoaded: true,
|
||||||
|
isSignedIn: hasLocalAuthToken(),
|
||||||
|
user: null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
if (!isClerkEnabled()) {
|
if (!isClerkEnabled()) {
|
||||||
return { isLoaded: true, isSignedIn: false, user: null } as const;
|
return { isLoaded: true, isSignedIn: false, user: null } as const;
|
||||||
}
|
}
|
||||||
@@ -56,6 +75,16 @@ export function useUser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useAuth() {
|
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()) {
|
if (!isClerkEnabled()) {
|
||||||
return {
|
return {
|
||||||
isLoaded: true,
|
isLoaded: true,
|
||||||
|
|||||||
45
frontend/src/auth/localAuth.ts
Normal file
45
frontend/src/auth/localAuth.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AuthMode } from "@/auth/mode";
|
||||||
|
|
||||||
|
let localToken: string | null = null;
|
||||||
|
const STORAGE_KEY = "mc_local_auth_token";
|
||||||
|
|
||||||
|
export function isLocalAuthMode(): boolean {
|
||||||
|
return process.env.NEXT_PUBLIC_AUTH_MODE === AuthMode.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).
|
||||||
|
}
|
||||||
|
}
|
||||||
4
frontend/src/auth/mode.ts
Normal file
4
frontend/src/auth/mode.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export enum AuthMode {
|
||||||
|
Clerk = "clerk",
|
||||||
|
Local = "local",
|
||||||
|
}
|
||||||
116
frontend/src/components/organisms/LocalAuthLogin.test.tsx
Normal file
116
frontend/src/components/organisms/LocalAuthLogin.test.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
|
import { LocalAuthLogin } from "./LocalAuthLogin";
|
||||||
|
|
||||||
|
const setLocalAuthTokenMock = vi.hoisted(() => vi.fn());
|
||||||
|
const fetchMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("@/auth/localAuth", async () => {
|
||||||
|
const actual =
|
||||||
|
await vi.importActual<typeof import("@/auth/localAuth")>(
|
||||||
|
"@/auth/localAuth",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
setLocalAuthToken: setLocalAuthTokenMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LocalAuthLogin", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.mockReset();
|
||||||
|
setLocalAuthTokenMock.mockReset();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
vi.stubEnv("NEXT_PUBLIC_API_URL", "http://localhost:8000/");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a non-empty token", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LocalAuthLogin />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("Bearer token is required.")).toBeInTheDocument();
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(setLocalAuthTokenMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires token length of at least 50 characters", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LocalAuthLogin />);
|
||||||
|
|
||||||
|
await user.type(screen.getByPlaceholderText("Paste token"), "x".repeat(49));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("Bearer token must be at least 50 characters."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(setLocalAuthTokenMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid token values", async () => {
|
||||||
|
const onAuthenticatedMock = vi.fn();
|
||||||
|
fetchMock.mockResolvedValueOnce(new Response(null, { status: 401 }));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LocalAuthLogin onAuthenticated={onAuthenticatedMock} />);
|
||||||
|
|
||||||
|
await user.type(screen.getByPlaceholderText("Paste token"), "x".repeat(50));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("Token is invalid.")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"http://localhost:8000/api/v1/users/me",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "GET",
|
||||||
|
headers: { Authorization: `Bearer ${"x".repeat(50)}` },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(setLocalAuthTokenMock).not.toHaveBeenCalled();
|
||||||
|
expect(onAuthenticatedMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves token only after successful backend validation", async () => {
|
||||||
|
const onAuthenticatedMock = vi.fn();
|
||||||
|
fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LocalAuthLogin onAuthenticated={onAuthenticatedMock} />);
|
||||||
|
|
||||||
|
const token = ` ${"g".repeat(50)} `;
|
||||||
|
await user.type(screen.getByPlaceholderText("Paste token"), token);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(setLocalAuthTokenMock).toHaveBeenCalledWith("g".repeat(50)),
|
||||||
|
);
|
||||||
|
expect(onAuthenticatedMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a clear error when backend is unreachable", async () => {
|
||||||
|
const onAuthenticatedMock = vi.fn();
|
||||||
|
fetchMock.mockRejectedValueOnce(new TypeError("network error"));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LocalAuthLogin onAuthenticated={onAuthenticatedMock} />);
|
||||||
|
|
||||||
|
await user.type(screen.getByPlaceholderText("Paste token"), "t".repeat(50));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByText("Unable to reach backend to validate token."),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(setLocalAuthTokenMock).not.toHaveBeenCalled();
|
||||||
|
expect(onAuthenticatedMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
148
frontend/src/components/organisms/LocalAuthLogin.tsx
Normal file
148
frontend/src/components/organisms/LocalAuthLogin.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"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";
|
||||||
|
|
||||||
|
const LOCAL_AUTH_TOKEN_MIN_LENGTH = 50;
|
||||||
|
|
||||||
|
async function validateLocalToken(token: string): Promise<string | null> {
|
||||||
|
const rawBaseUrl = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
if (!rawBaseUrl) {
|
||||||
|
return "NEXT_PUBLIC_API_URL is not set.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = rawBaseUrl.replace(/\/+$/, "");
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${baseUrl}/api/v1/users/me`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return "Unable to reach backend to validate token.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
return "Token is invalid.";
|
||||||
|
}
|
||||||
|
return `Unable to validate token (HTTP ${response.status}).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocalAuthLoginProps = {
|
||||||
|
onAuthenticated?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultOnAuthenticated = () => window.location.reload();
|
||||||
|
|
||||||
|
export function LocalAuthLogin({ onAuthenticated }: LocalAuthLoginProps) {
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const cleaned = token.trim();
|
||||||
|
if (!cleaned) {
|
||||||
|
setError("Bearer token is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cleaned.length < LOCAL_AUTH_TOKEN_MIN_LENGTH) {
|
||||||
|
setError(
|
||||||
|
`Bearer token must be at least ${LOCAL_AUTH_TOKEN_MIN_LENGTH} characters.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsValidating(true);
|
||||||
|
const validationError = await validateLocalToken(cleaned);
|
||||||
|
setIsValidating(false);
|
||||||
|
if (validationError) {
|
||||||
|
setError(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocalAuthToken(cleaned);
|
||||||
|
setError(null);
|
||||||
|
(onAuthenticated ?? defaultOnAuthenticated)();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-app px-4 py-10">
|
||||||
|
<div className="pointer-events-none absolute inset-0">
|
||||||
|
<div className="absolute -top-28 -left-24 h-72 w-72 rounded-full bg-[color:var(--accent-soft)] blur-3xl" />
|
||||||
|
<div className="absolute -right-28 -bottom-24 h-80 w-80 rounded-full bg-[rgba(14,165,233,0.12)] blur-3xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="relative w-full max-w-lg animate-fade-in-up">
|
||||||
|
<CardHeader className="space-y-5 border-b border-[color:var(--border)] pb-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="rounded-full border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-1 text-xs font-semibold uppercase tracking-[0.08em] text-muted">
|
||||||
|
Self-host mode
|
||||||
|
</span>
|
||||||
|
<div className="rounded-xl bg-[color:var(--accent-soft)] p-2 text-[color:var(--accent)]">
|
||||||
|
<Lock className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-strong">
|
||||||
|
Local Authentication
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Enter your access token to unlock Mission Control.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-5">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="local-auth-token"
|
||||||
|
className="text-xs font-semibold uppercase tracking-[0.08em] text-muted"
|
||||||
|
>
|
||||||
|
Access token
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="local-auth-token"
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(event) => setToken(event.target.value)}
|
||||||
|
placeholder="Paste token"
|
||||||
|
autoFocus
|
||||||
|
disabled={isValidating}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Token must be at least {LOCAL_AUTH_TOKEN_MIN_LENGTH} characters.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
disabled={isValidating}
|
||||||
|
>
|
||||||
|
{isValidating ? "Validating..." : "Continue"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SignOutButton, useUser } from "@/auth/clerk";
|
import { SignOutButton, useUser } from "@/auth/clerk";
|
||||||
|
import { clearLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Bot,
|
Bot,
|
||||||
@@ -36,13 +37,17 @@ export function UserMenu({
|
|||||||
}: UserMenuProps) {
|
}: UserMenuProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
if (!user) return null;
|
const localMode = isLocalAuthMode();
|
||||||
|
if (!user && !localMode) return null;
|
||||||
|
|
||||||
const avatarUrl = user.imageUrl ?? null;
|
const avatarUrl = localMode ? null : (user?.imageUrl ?? null);
|
||||||
const avatarLabelSource = displayNameFromDb ?? user.id ?? "U";
|
const avatarLabelSource =
|
||||||
|
displayNameFromDb ?? (localMode ? "Local User" : user?.id) ?? "U";
|
||||||
const avatarLabel = avatarLabelSource.slice(0, 1).toUpperCase();
|
const avatarLabel = avatarLabelSource.slice(0, 1).toUpperCase();
|
||||||
const displayName = displayNameFromDb ?? "Account";
|
const displayName =
|
||||||
const displayEmail = displayEmailFromDb ?? "";
|
displayNameFromDb ?? (localMode ? "Local User" : "Account");
|
||||||
|
const displayEmail =
|
||||||
|
displayEmailFromDb ?? (localMode ? "local@localhost" : "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
@@ -166,16 +171,31 @@ export function UserMenu({
|
|||||||
|
|
||||||
<div className="my-2 h-px bg-[color:var(--neutral-200,var(--border))]" />
|
<div className="my-2 h-px bg-[color:var(--neutral-200,var(--border))]" />
|
||||||
|
|
||||||
<SignOutButton>
|
{localMode ? (
|
||||||
<button
|
<button
|
||||||
type="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"
|
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={() => setOpen(false)}
|
onClick={() => {
|
||||||
|
clearLocalAuthToken();
|
||||||
|
setOpen(false);
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4 text-[color:var(--neutral-700,var(--text-quiet))]" />
|
<LogOut className="h-4 w-4 text-[color:var(--neutral-700,var(--text-quiet))]" />
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
</SignOutButton>
|
) : (
|
||||||
|
<SignOutButton>
|
||||||
|
<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={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 text-[color:var(--neutral-700,var(--text-quiet))]" />
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</SignOutButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -1,11 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ClerkProvider } from "@clerk/nextjs";
|
import { ClerkProvider } from "@clerk/nextjs";
|
||||||
import type { ReactNode } from "react";
|
import { useEffect, type ReactNode } from "react";
|
||||||
|
|
||||||
import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey";
|
import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey";
|
||||||
|
import {
|
||||||
|
clearLocalAuthToken,
|
||||||
|
getLocalAuthToken,
|
||||||
|
isLocalAuthMode,
|
||||||
|
} from "@/auth/localAuth";
|
||||||
|
import { LocalAuthLogin } from "@/components/organisms/LocalAuthLogin";
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const localMode = isLocalAuthMode();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!localMode) {
|
||||||
|
clearLocalAuthToken();
|
||||||
|
}
|
||||||
|
}, [localMode]);
|
||||||
|
|
||||||
|
if (localMode) {
|
||||||
|
if (!getLocalAuthToken()) {
|
||||||
|
return <LocalAuthLogin />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY;
|
const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY;
|
||||||
const afterSignOutUrl =
|
const afterSignOutUrl =
|
||||||
process.env.NEXT_PUBLIC_CLERK_AFTER_SIGN_OUT_URL ?? "/";
|
process.env.NEXT_PUBLIC_CLERK_AFTER_SIGN_OUT_URL ?? "/";
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { NextResponse } from "next/server";
|
|||||||
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
|
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
|
||||||
|
|
||||||
import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey";
|
import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey";
|
||||||
|
import { AuthMode } from "@/auth/mode";
|
||||||
|
|
||||||
const isClerkEnabled = () =>
|
const isClerkEnabled = () =>
|
||||||
|
process.env.NEXT_PUBLIC_AUTH_MODE !== AuthMode.Local &&
|
||||||
isLikelyValidClerkPublishableKey(
|
isLikelyValidClerkPublishableKey(
|
||||||
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user