+
+ Presenton
+Preparing your workspace…
+diff --git a/README.md b/README.md
index 78534c81..348521ca 100644
--- a/README.md
+++ b/README.md
@@ -241,6 +241,53 @@ You can disable anonymous telemetry using the following environment variable:
- DISABLE_ANONYMOUS_TRACKING=[true/false]: Set this to **true** to disable anonymous telemetry.
+### Web login (Docker / self-hosted)
+
+The web image can require a single admin username and password before the app and API are usable. Credentials are stored under your `app_data` volume (hashed in `userConfig.json`). Optional environment variables (also wired in `docker-compose.yml` for `production`, `production-gpu`, `development`, and `development-gpu`):
+
+- **AUTH_USERNAME** / **AUTH_PASSWORD** — Preseed the admin login on first boot (password at least 6 characters). If credentials already exist, these are ignored unless **AUTH_OVERRIDE_FROM_ENV** is set.
+- **AUTH_OVERRIDE_FROM_ENV**=[true/false] — If **true**, overwrite stored credentials from **AUTH_USERNAME** / **AUTH_PASSWORD** on every container start and rotate the session signing secret (all existing sessions end). Remove after a one-off rotation.
+- **RESET_AUTH**=[true/false] — If **true**, clear stored login data on startup (recovery). Use for one boot only, then unset so credentials are not wiped again.
+
+**Examples**
+
+Default (first visit opens the setup UI on `/`):
+
+```bash
+docker compose up -d production
+# open http://localhost:5000
+```
+
+Preseed credentials via `.env` then start:
+
+```bash
+# .env
+AUTH_USERNAME=admin
+AUTH_PASSWORD=your-secure-password
+
+docker compose up -d production
+```
+
+Rotate password from the environment (then remove `AUTH_OVERRIDE_FROM_ENV` from `.env` and redeploy):
+
+```bash
+AUTH_USERNAME=admin
+AUTH_PASSWORD=new-password
+AUTH_OVERRIDE_FROM_ENV=true
+docker compose up -d production
+```
+
+Locked out — reset and set up again:
+
+```bash
+RESET_AUTH=true docker compose up -d production
+# after one successful start, remove RESET_AUTH from .env and run compose again
+```
+
+**Manual reset:** stop the container, edit `./app_data/userConfig.json`, delete `AUTH_USERNAME`, `AUTH_PASSWORD_HASH`, and `AUTH_SECRET_KEY`, save, and start again.
+
+Sign out from the app: **Settings → Other → Sign out**.
+
> Note: You can freely choose both the LLM (text generation) and the image provider. Supported image providers: **dall-e-3**, **gpt-image-1.5** (OpenAI), **gemini_flash**, **nanobanana_pro** (Google), **pexels**, **pixabay**, and **comfyui** (self-hosted).
diff --git a/docker-compose.yml b/docker-compose.yml
index 70b64627..348bd22a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -45,6 +45,12 @@ services:
- LITEPARSE_NUM_WORKERS=${LITEPARSE_NUM_WORKERS:-1}
- OPEN_WEBUI_IMAGE_URL=${OPEN_WEBUI_IMAGE_URL}
- OPEN_WEBUI_IMAGE_API_KEY=${OPEN_WEBUI_IMAGE_API_KEY}
+ # Single-user login (see AUTHENTICATION.md).
+ # Leave all three unset to let the UI collect credentials on first visit.
+ - AUTH_USERNAME=${AUTH_USERNAME:-}
+ - AUTH_PASSWORD=${AUTH_PASSWORD:-}
+ - AUTH_OVERRIDE_FROM_ENV=${AUTH_OVERRIDE_FROM_ENV:-}
+ - RESET_AUTH=${RESET_AUTH:-}
production-gpu:
# image: ghcr.io/presenton/presenton:latest
@@ -97,9 +103,13 @@ services:
- MEM0_EMBEDDING_DIMS=${MEM0_EMBEDDING_DIMS:-384}
- LITEPARSE_DPI=${LITEPARSE_DPI:-120}
- LITEPARSE_NUM_WORKERS=${LITEPARSE_NUM_WORKERS:-1}
-
+
- OPEN_WEBUI_IMAGE_URL=${OPEN_WEBUI_IMAGE_URL}
- OPEN_WEBUI_IMAGE_API_KEY=${OPEN_WEBUI_IMAGE_API_KEY}
+ - AUTH_USERNAME=${AUTH_USERNAME:-}
+ - AUTH_PASSWORD=${AUTH_PASSWORD:-}
+ - AUTH_OVERRIDE_FROM_ENV=${AUTH_OVERRIDE_FROM_ENV:-}
+ - RESET_AUTH=${RESET_AUTH:-}
development:
build:
@@ -150,6 +160,10 @@ services:
- LITEPARSE_NUM_WORKERS=${LITEPARSE_NUM_WORKERS:-1}
- OPEN_WEBUI_IMAGE_URL=${OPEN_WEBUI_IMAGE_URL}
- OPEN_WEBUI_IMAGE_API_KEY=${OPEN_WEBUI_IMAGE_API_KEY}
+ - AUTH_USERNAME=${AUTH_USERNAME:-}
+ - AUTH_PASSWORD=${AUTH_PASSWORD:-}
+ - AUTH_OVERRIDE_FROM_ENV=${AUTH_OVERRIDE_FROM_ENV:-}
+ - RESET_AUTH=${RESET_AUTH:-}
development-gpu:
build:
@@ -206,6 +220,10 @@ services:
- LITEPARSE_NUM_WORKERS=${LITEPARSE_NUM_WORKERS:-1}
- OPEN_WEBUI_IMAGE_URL=${OPEN_WEBUI_IMAGE_URL}
- OPEN_WEBUI_IMAGE_API_KEY=${OPEN_WEBUI_IMAGE_API_KEY}
+ - AUTH_USERNAME=${AUTH_USERNAME:-}
+ - AUTH_PASSWORD=${AUTH_PASSWORD:-}
+ - AUTH_OVERRIDE_FROM_ENV=${AUTH_OVERRIDE_FROM_ENV:-}
+ - RESET_AUTH=${RESET_AUTH:-}
volumes:
presenton_root_node_modules:
diff --git a/nginx.conf b/nginx.conf
index a61a9c9b..402814ef 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -21,7 +21,13 @@ http {
proxy_http_version 1.1; # Required for WebSocket
proxy_set_header Upgrade $http_upgrade; # WebSocket header
proxy_set_header Connection "upgrade"; # WebSocket header
- proxy_set_header Host $host;
+ # Preserve browser host:port (e.g. localhost:5000). $host strips the port
+ # and breaks Next.js redirects / absolute URLs.
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $http_host;
proxy_read_timeout 30m;
proxy_connect_timeout 30m;
}
@@ -30,6 +36,10 @@ http {
proxy_pass http://localhost:8000;
proxy_read_timeout 30m;
proxy_connect_timeout 30m;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
}
# MCP
@@ -58,15 +68,37 @@ http {
proxy_pass http://localhost:8000/docs;
proxy_read_timeout 30m;
proxy_connect_timeout 30m;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
}
location /openapi.json {
proxy_pass http://localhost:8000/openapi.json;
proxy_read_timeout 30m;
proxy_connect_timeout 30m;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
}
- # Static
+ # Internal auth subrequest used to gate /app_data/* static access behind the
+ # FastAPI session cookie. Nginx serves these files directly via `alias` for
+ # performance, so auth_request is what keeps them from being public.
+ location = /_auth_check {
+ internal;
+ proxy_pass http://localhost:8000/api/v1/auth/verify;
+ proxy_pass_request_body off;
+ proxy_set_header Content-Length "";
+ proxy_set_header X-Original-URI $request_uri;
+ proxy_set_header Host $host;
+ proxy_set_header Cookie $http_cookie;
+ }
+
+ # Bundled UI static assets (logos, fonts packaged with the app) are safe to
+ # serve unauthenticated; they contain no user data.
location /static {
alias /app/servers/fastapi/static/;
expires 1y;
@@ -74,33 +106,38 @@ http {
}
location /app_data/images/ {
+ auth_request /_auth_check;
alias /app_data/images/;
expires 1y;
- add_header Cache-Control "public, immutable";
+ add_header Cache-Control "private, max-age=31536000";
}
location /app_data/exports/ {
+ auth_request /_auth_check;
alias /app_data/exports/;
expires 1y;
- add_header Cache-Control "public, immutable";
+ add_header Cache-Control "private, max-age=31536000";
}
location /app_data/uploads/ {
+ auth_request /_auth_check;
alias /app_data/uploads/;
expires 1y;
- add_header Cache-Control "public, immutable";
+ add_header Cache-Control "private, max-age=31536000";
}
location /app_data/fonts/ {
+ auth_request /_auth_check;
alias /app_data/fonts/;
expires 1y;
- add_header Cache-Control "public, immutable";
+ add_header Cache-Control "private, max-age=31536000";
}
location /app_data/pptx-to-html/ {
+ auth_request /_auth_check;
alias /app_data/pptx-to-html/;
expires 1y;
- add_header Cache-Control "public, immutable";
+ add_header Cache-Control "private, max-age=31536000";
}
}
}
\ No newline at end of file
diff --git a/servers/fastapi/api/lifespan.py b/servers/fastapi/api/lifespan.py
index 1ce3e26f..2f6b624d 100644
--- a/servers/fastapi/api/lifespan.py
+++ b/servers/fastapi/api/lifespan.py
@@ -1,4 +1,5 @@
from contextlib import asynccontextmanager
+import logging
import os
from fastapi import FastAPI
@@ -9,6 +10,67 @@ from utils.get_env import get_app_data_directory_env
from utils.model_availability import (
check_llm_and_image_provider_api_or_model_availability,
)
+from utils.simple_auth import (
+ clear_stored_credentials,
+ force_set_credentials,
+ is_auth_configured,
+ setup_initial_credentials,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _is_truthy(value: str | None) -> bool:
+ if value is None:
+ return False
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+
+
+def _bootstrap_auth_from_env() -> None:
+ """
+ Bootstrap the single-user login from environment variables.
+
+ Behaviour:
+ - RESET_AUTH=true -> wipe stored credentials (recovery path).
+ - AUTH_USERNAME + AUTH_PASSWORD set:
+ * if no credentials configured -> create them (first-run preseed).
+ * if AUTH_OVERRIDE_FROM_ENV=true -> overwrite existing credentials.
+ - Otherwise do nothing; the login UI will run in setup-mode on first
+ visit and in sign-in-mode afterwards.
+
+ Any errors here are logged and swallowed so a bad env value can never
+ brick the app — the operator can always fall back to the UI/reset flow.
+ """
+ try:
+ if _is_truthy(os.getenv("RESET_AUTH")):
+ clear_stored_credentials()
+ logger.warning(
+ "RESET_AUTH is set; cleared stored login credentials. "
+ "The next visit will prompt for setup."
+ )
+
+ env_username = os.getenv("AUTH_USERNAME")
+ env_password = os.getenv("AUTH_PASSWORD")
+ if not env_username or not env_password:
+ return
+
+ override = _is_truthy(os.getenv("AUTH_OVERRIDE_FROM_ENV"))
+ if is_auth_configured() and not override:
+ return
+
+ if is_auth_configured() and override:
+ force_set_credentials(env_username, env_password)
+ logger.warning(
+ "AUTH_OVERRIDE_FROM_ENV is set; replaced stored credentials "
+ "with values from AUTH_USERNAME/AUTH_PASSWORD."
+ )
+ else:
+ setup_initial_credentials(env_username, env_password)
+ logger.info(
+ "Initialized login credentials from AUTH_USERNAME/AUTH_PASSWORD."
+ )
+ except Exception as exc: # pragma: no cover - defensive, never fatal.
+ logger.exception("Failed to bootstrap auth from environment: %s", exc)
@asynccontextmanager
@@ -16,12 +78,14 @@ async def app_lifespan(_: FastAPI):
"""
Lifespan context manager for FastAPI application.
Initializes the application data directory, runs Alembic migrations when
- MIGRATE_DATABASE_ON_STARTUP=true, creates any missing tables, and checks
- LLM model availability.
+ MIGRATE_DATABASE_ON_STARTUP=true, creates any missing tables, bootstraps
+ the single-user login from env vars (if provided), and checks LLM model
+ availability.
"""
os.makedirs(get_app_data_directory_env(), exist_ok=True)
await migrate_database_on_startup()
await create_db_and_tables()
+ _bootstrap_auth_from_env()
await check_llm_and_image_provider_api_or_model_availability()
yield
# Shutdown: release all database connections to prevent stale/leaked pools.
diff --git a/servers/fastapi/api/main.py b/servers/fastapi/api/main.py
index d0f9ae80..159a0f4e 100644
--- a/servers/fastapi/api/main.py
+++ b/servers/fastapi/api/main.py
@@ -5,7 +5,8 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from api.lifespan import app_lifespan
-from api.middlewares import UserConfigEnvUpdateMiddleware
+from api.middlewares import SessionAuthMiddleware, UserConfigEnvUpdateMiddleware
+from api.v1.auth.router import API_V1_AUTH_ROUTER
from api.v1.mock.router import API_V1_MOCK_ROUTER
from api.v1.ppt.router import API_V1_PPT_ROUTER
from api.v1.webhook.router import API_V1_WEBHOOK_ROUTER
@@ -18,6 +19,7 @@ app = FastAPI(lifespan=app_lifespan)
app.include_router(API_V1_PPT_ROUTER)
app.include_router(API_V1_WEBHOOK_ROUTER)
app.include_router(API_V1_MOCK_ROUTER)
+app.include_router(API_V1_AUTH_ROUTER)
# Mount app_data and static assets (direct FastAPI access; nginx also serves /static in Docker).
app_data_dir = get_app_data_directory_env()
@@ -40,3 +42,4 @@ app.add_middleware(
)
app.add_middleware(UserConfigEnvUpdateMiddleware)
+app.add_middleware(SessionAuthMiddleware)
diff --git a/servers/fastapi/api/middlewares.py b/servers/fastapi/api/middlewares.py
index f5c0c3f0..fb45e7f1 100644
--- a/servers/fastapi/api/middlewares.py
+++ b/servers/fastapi/api/middlewares.py
@@ -1,7 +1,9 @@
from fastapi import Request
+from starlette.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from utils.get_env import get_can_change_keys_env
+from utils.simple_auth import get_auth_status, get_session_token_from_request
from utils.user_config import update_env_with_user_config
@@ -10,3 +12,53 @@ class UserConfigEnvUpdateMiddleware(BaseHTTPMiddleware):
if get_can_change_keys_env() != "false":
update_env_with_user_config()
return await call_next(request)
+
+
+class SessionAuthMiddleware(BaseHTTPMiddleware):
+ _EXEMPT_PREFIXES = (
+ "/api/v1/auth/",
+ )
+ _PROTECTED_NON_API_PATHS = {
+ "/docs",
+ "/openapi.json",
+ "/redoc",
+ }
+
+ def _is_exempt(self, path: str) -> bool:
+ return any(path.startswith(prefix) for prefix in self._EXEMPT_PREFIXES)
+
+ def _requires_auth(self, path: str) -> bool:
+ if path.startswith("/api/"):
+ return True
+ if path.startswith("/app_data/"):
+ return True
+ return path in self._PROTECTED_NON_API_PATHS
+
+ async def dispatch(self, request: Request, call_next):
+ path = request.url.path
+
+ if (
+ request.method == "OPTIONS"
+ or not self._requires_auth(path)
+ or self._is_exempt(path)
+ ):
+ return await call_next(request)
+
+ auth_status = get_auth_status(get_session_token_from_request(request))
+ if not auth_status["configured"]:
+ return JSONResponse(
+ status_code=428,
+ content={
+ "detail": "Login setup is required",
+ "setup_required": True,
+ },
+ )
+
+ if not auth_status["authenticated"]:
+ return JSONResponse(
+ status_code=401,
+ content={"detail": "Unauthorized"},
+ )
+
+ request.state.auth_username = auth_status.get("username")
+ return await call_next(request)
diff --git a/servers/fastapi/api/v1/auth/router.py b/servers/fastapi/api/v1/auth/router.py
new file mode 100644
index 00000000..17b75dba
--- /dev/null
+++ b/servers/fastapi/api/v1/auth/router.py
@@ -0,0 +1,85 @@
+from fastapi import APIRouter, HTTPException, Request
+from pydantic import BaseModel, Field
+from starlette.responses import JSONResponse
+
+from utils.simple_auth import (
+ clear_session_cookie,
+ create_session_token,
+ get_auth_status,
+ get_session_token_from_request,
+ is_auth_configured,
+ set_session_cookie,
+ setup_initial_credentials,
+ verify_credentials,
+)
+
+API_V1_AUTH_ROUTER = APIRouter(prefix="/api/v1/auth", tags=["Auth"])
+
+
+class AuthCredentialsRequest(BaseModel):
+ username: str = Field(min_length=3, max_length=128)
+ password: str = Field(min_length=6, max_length=256)
+
+
+@API_V1_AUTH_ROUTER.get("/status")
+async def get_status(request: Request):
+ token = get_session_token_from_request(request)
+ return get_auth_status(token)
+
+
+@API_V1_AUTH_ROUTER.get("/verify")
+async def verify_session(request: Request):
+ auth_status = get_auth_status(get_session_token_from_request(request))
+ if not auth_status["configured"] or not auth_status["authenticated"]:
+ raise HTTPException(status_code=401, detail="Unauthorized")
+
+ return {
+ "authenticated": True,
+ "username": auth_status.get("username"),
+ }
+
+
+@API_V1_AUTH_ROUTER.post("/setup")
+async def setup_credentials(body: AuthCredentialsRequest, request: Request):
+ if is_auth_configured():
+ raise HTTPException(status_code=409, detail="Credentials already configured")
+
+ try:
+ setup_initial_credentials(body.username, body.password)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+ token = create_session_token(body.username.strip())
+ response = JSONResponse(
+ {
+ "configured": True,
+ "authenticated": True,
+ "username": body.username.strip(),
+ }
+ )
+ set_session_cookie(response, token, request)
+ return response
+
+
+@API_V1_AUTH_ROUTER.post("/login")
+async def login(body: AuthCredentialsRequest, request: Request):
+ if not is_auth_configured():
+ raise HTTPException(status_code=428, detail="Login setup is required")
+
+ if not verify_credentials(body.username, body.password):
+ raise HTTPException(status_code=401, detail="Unauthorized")
+
+ username = body.username.strip()
+ token = create_session_token(username)
+ response = JSONResponse(
+ {"configured": True, "authenticated": True, "username": username}
+ )
+ set_session_cookie(response, token, request)
+ return response
+
+
+@API_V1_AUTH_ROUTER.post("/logout")
+async def logout(request: Request):
+ response = JSONResponse({"success": True})
+ clear_session_cookie(response, request)
+ return response
diff --git a/servers/fastapi/utils/simple_auth.py b/servers/fastapi/utils/simple_auth.py
new file mode 100644
index 00000000..5eb23f50
--- /dev/null
+++ b/servers/fastapi/utils/simple_auth.py
@@ -0,0 +1,292 @@
+import base64
+import hashlib
+import hmac
+import json
+import os
+import secrets
+import time
+from typing import Optional
+
+from fastapi import Request
+from starlette.responses import Response
+
+from utils.get_env import get_user_config_path_env
+
+SESSION_COOKIE_NAME = "presenton_session"
+PBKDF2_ITERATIONS = 200_000
+SESSION_TTL_SECONDS = 60 * 60 * 24 * 30
+
+
+def _base64url_encode(data: bytes) -> str:
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8")
+
+
+def _base64url_decode(value: str) -> bytes:
+ padded = value + "=" * (-len(value) % 4)
+ return base64.urlsafe_b64decode(padded.encode("utf-8"))
+
+
+def _load_user_config() -> dict:
+ user_config_path = get_user_config_path_env()
+ if not user_config_path or not os.path.exists(user_config_path):
+ return {}
+
+ try:
+ with open(user_config_path, "r", encoding="utf-8") as config_file:
+ data = json.load(config_file)
+ return data if isinstance(data, dict) else {}
+ except Exception:
+ return {}
+
+
+def _save_user_config(config: dict) -> None:
+ user_config_path = get_user_config_path_env()
+ if not user_config_path:
+ raise ValueError("USER_CONFIG_PATH is not set")
+
+ os.makedirs(os.path.dirname(user_config_path), exist_ok=True)
+ with open(user_config_path, "w", encoding="utf-8") as config_file:
+ json.dump(config, config_file)
+
+
+def _hash_password(password: str, salt: bytes) -> bytes:
+ return hashlib.pbkdf2_hmac(
+ "sha256", password.encode("utf-8"), salt, PBKDF2_ITERATIONS
+ )
+
+
+def _encode_password_hash(password: str) -> str:
+ salt = secrets.token_bytes(16)
+ digest = _hash_password(password, salt)
+ salt_encoded = _base64url_encode(salt)
+ digest_encoded = _base64url_encode(digest)
+ return (
+ f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt_encoded}${digest_encoded}"
+ )
+
+
+def _verify_password_hash(password: str, encoded_hash: str) -> bool:
+ try:
+ algorithm, iterations_str, salt_encoded, digest_encoded = encoded_hash.split("$")
+ if algorithm != "pbkdf2_sha256":
+ return False
+
+ iterations = int(iterations_str)
+ salt = _base64url_decode(salt_encoded)
+ expected_digest = _base64url_decode(digest_encoded)
+ actual_digest = hashlib.pbkdf2_hmac(
+ "sha256", password.encode("utf-8"), salt, iterations
+ )
+ return hmac.compare_digest(actual_digest, expected_digest)
+ except Exception:
+ return False
+
+
+def _get_or_create_auth_secret(config: dict) -> str:
+ secret = config.get("AUTH_SECRET_KEY")
+ if secret:
+ return secret
+
+ secret = _base64url_encode(secrets.token_bytes(32))
+ config["AUTH_SECRET_KEY"] = secret
+ _save_user_config(config)
+ return secret
+
+
+def is_auth_configured() -> bool:
+ config = _load_user_config()
+ return bool(config.get("AUTH_USERNAME") and config.get("AUTH_PASSWORD_HASH"))
+
+
+def setup_initial_credentials(username: str, password: str) -> None:
+ cleaned_username = (username or "").strip()
+ if len(cleaned_username) < 3:
+ raise ValueError("Username must be at least 3 characters")
+
+ if len(password or "") < 6:
+ raise ValueError("Password must be at least 6 characters")
+
+ config = _load_user_config()
+ if config.get("AUTH_USERNAME") and config.get("AUTH_PASSWORD_HASH"):
+ raise ValueError("Credentials already configured")
+
+ config["AUTH_USERNAME"] = cleaned_username
+ config["AUTH_PASSWORD_HASH"] = _encode_password_hash(password)
+ _get_or_create_auth_secret(config)
+ _save_user_config(config)
+
+
+def force_set_credentials(username: str, password: str) -> None:
+ """Overwrite stored credentials; used by env-based preseed/override."""
+ cleaned_username = (username or "").strip()
+ if len(cleaned_username) < 3:
+ raise ValueError("Username must be at least 3 characters")
+
+ if len(password or "") < 6:
+ raise ValueError("Password must be at least 6 characters")
+
+ config = _load_user_config()
+ config["AUTH_USERNAME"] = cleaned_username
+ config["AUTH_PASSWORD_HASH"] = _encode_password_hash(password)
+ # Rotate the signing secret so any previously-issued tokens stop validating.
+ config["AUTH_SECRET_KEY"] = _base64url_encode(secrets.token_bytes(32))
+ _save_user_config(config)
+
+
+def clear_stored_credentials() -> None:
+ """Remove stored credentials; next boot will request setup again."""
+ config = _load_user_config()
+ removed = False
+ for key in ("AUTH_USERNAME", "AUTH_PASSWORD_HASH", "AUTH_SECRET_KEY"):
+ if key in config:
+ config.pop(key, None)
+ removed = True
+ if removed:
+ _save_user_config(config)
+
+
+def verify_credentials(username: str, password: str) -> bool:
+ config = _load_user_config()
+ stored_username = config.get("AUTH_USERNAME")
+ stored_hash = config.get("AUTH_PASSWORD_HASH")
+
+ if not stored_username or not stored_hash:
+ return False
+
+ cleaned_username = (username or "").strip()
+ if not hmac.compare_digest(cleaned_username, stored_username):
+ return False
+
+ return _verify_password_hash(password or "", stored_hash)
+
+
+def _sign_payload(payload_encoded: str, secret: str) -> str:
+ signature = hmac.new(
+ secret.encode("utf-8"), payload_encoded.encode("utf-8"), hashlib.sha256
+ ).digest()
+ return _base64url_encode(signature)
+
+
+def create_session_token(username: str) -> str:
+ config = _load_user_config()
+ secret = _get_or_create_auth_secret(config)
+
+ issued_at = int(time.time())
+ payload = {
+ "v": 1,
+ "u": username,
+ "iat": issued_at,
+ "exp": issued_at + SESSION_TTL_SECONDS,
+ }
+
+ payload_encoded = _base64url_encode(
+ json.dumps(payload, separators=(",", ":")).encode("utf-8")
+ )
+ signature_encoded = _sign_payload(payload_encoded, secret)
+ return f"{payload_encoded}.{signature_encoded}"
+
+
+def validate_session_token(token: Optional[str]) -> Optional[str]:
+ if not token:
+ return None
+
+ config = _load_user_config()
+ stored_username = config.get("AUTH_USERNAME")
+ if not stored_username:
+ return None
+
+ secret = config.get("AUTH_SECRET_KEY")
+ if not secret:
+ return None
+
+ try:
+ payload_encoded, signature_encoded = token.split(".", 1)
+ except ValueError:
+ return None
+
+ expected_signature = _sign_payload(payload_encoded, secret)
+ if not hmac.compare_digest(signature_encoded, expected_signature):
+ return None
+
+ try:
+ payload_raw = _base64url_decode(payload_encoded)
+ payload = json.loads(payload_raw)
+ except Exception:
+ return None
+
+ username = payload.get("u")
+ version = payload.get("v")
+ expires_at = payload.get("exp")
+ if not isinstance(username, str) or not isinstance(expires_at, int):
+ return None
+
+ if version != 1:
+ return None
+
+ if not hmac.compare_digest(username, stored_username):
+ return None
+
+ if expires_at < int(time.time()):
+ return None
+
+ return username
+
+
+def get_session_token_from_request(request: Request) -> Optional[str]:
+ cookie_token = request.cookies.get(SESSION_COOKIE_NAME)
+ if cookie_token:
+ return cookie_token
+
+ auth_header = request.headers.get("Authorization", "")
+ if auth_header.lower().startswith("bearer "):
+ return auth_header[7:].strip() or None
+
+ return None
+
+
+def get_auth_status(session_token: Optional[str] = None) -> dict:
+ config = _load_user_config()
+ configured = bool(config.get("AUTH_USERNAME") and config.get("AUTH_PASSWORD_HASH"))
+
+ if not configured:
+ return {
+ "configured": False,
+ "authenticated": False,
+ "username": None,
+ }
+
+ username = validate_session_token(session_token)
+ return {
+ "configured": True,
+ "authenticated": bool(username),
+ "username": username,
+ }
+
+
+def _is_secure_request(request: Request) -> bool:
+ forwarded_proto = request.headers.get("x-forwarded-proto", "")
+ if forwarded_proto.lower() == "https":
+ return True
+ return request.url.scheme == "https"
+
+
+def set_session_cookie(response: Response, token: str, request: Request) -> None:
+ response.set_cookie(
+ key=SESSION_COOKIE_NAME,
+ value=token,
+ max_age=SESSION_TTL_SECONDS,
+ httponly=True,
+ secure=_is_secure_request(request),
+ samesite="lax",
+ path="/",
+ )
+
+
+def clear_session_cookie(response: Response, request: Request) -> None:
+ response.delete_cookie(
+ key=SESSION_COOKIE_NAME,
+ httponly=True,
+ secure=_is_secure_request(request),
+ samesite="lax",
+ path="/",
+ )
diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/Components/DashboardSidebar.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/Components/DashboardSidebar.tsx
index 25535f3c..7ba516b0 100644
--- a/servers/nextjs/app/(presentation-generator)/(dashboard)/Components/DashboardSidebar.tsx
+++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/Components/DashboardSidebar.tsx
@@ -4,11 +4,6 @@ import React from "react";
import { LayoutDashboard, Star, Brain, Settings, Palette, HelpCircle } from "lucide-react";
import { usePathname } from "next/navigation";
import Link from "next/link";
-import { useRouter } from "next/navigation";
-import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
-import { useSelector } from "react-redux";
-import { RootState } from "@/store/store";
-import { IMAGE_PROVIDERS, LLM_PROVIDERS } from "@/utils/providerConstants";
@@ -29,9 +24,6 @@ const DashboardSidebar = () => {
const pathname = usePathname();
const activeTab = pathname.split("?")[0].split("/").pop();
- const router = useRouter();
-
- const { llm_config } = useSelector((state: RootState) => state.userConfig)
@@ -136,8 +128,6 @@ const DashboardSidebar = () => {
);
})}
-
-
diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/Header.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/Header.tsx
index b971755e..88770fc8 100644
--- a/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/Header.tsx
+++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/Header.tsx
@@ -51,8 +51,8 @@ const Header = () => {
/>
- {showHeaderBack ? (
-
+ End your session on this deployment. You will need to sign in again to use the app and access the API. +
+Other
- +
+
+ Preparing your workspace…
+
+ + Secure instance +
++ {isSetupMode + ? "One-time setup for this deployment. You will use the same username and password on future visits." + : "This deployment is protected. Enter your credentials to open the app."} +
+ + {blockedAccessMessage ? ( +Select Mode
-Choose Providers
-Select Mode
+Choose Providers
+Finish Setup
Finish Setup