feat: implement single-user authentication with environment variable support and update FastAPI middleware for session management

This commit is contained in:
sudipnext 2026-04-21 17:41:37 +05:45
parent ba5d51ad76
commit effa9ad026
22 changed files with 1375 additions and 117 deletions

View file

@ -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).
<br>

View file

@ -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:

View file

@ -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";
}
}
}

View file

@ -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.

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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="/",
)

View file

@ -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 = () => {
);
})}
</div>
</aside>

View file

@ -51,8 +51,8 @@ const Header = () => {
/>
</Link>
</div>
{showHeaderBack ? (
<div>
<div className="flex items-center">
{showHeaderBack ? (
<Link
href={backHref}
className="text-[#333333] text-xs font-syne font-semibold flex items-center gap-2"
@ -63,9 +63,8 @@ const Header = () => {
<ArrowLeft className="w-4 h-4 shrink-0 text-[#333333]" aria-hidden />
<span>{backLabel}</span>
</Link>
</div>
) : null}
) : null}
</div>
</div>
</Wrapper>
</div>

View file

@ -24,6 +24,7 @@ import { IMAGE_PROVIDERS, LLM_PROVIDERS } from "@/utils/providerConstants";
import { ImagesApi } from "@/app/(presentation-generator)/services/api/images";
import { getApiUrl } from "@/utils/api";
import { toast } from "sonner";
import LogoutButton from "@/components/Auth/LogoutButton";
const STOCK_IMAGE_PROVIDERS = new Set(["pexels", "pixabay"]);
@ -41,7 +42,9 @@ const SettingsPage = () => {
const router = useRouter();
const pathname = usePathname();
const [mode, setMode] = useState<'nanobanana' | 'presenton'>('presenton')
const [selectedProvider, setSelectedProvider] = useState<'text-provider' | 'image-provider' | 'privacy'>('text-provider')
const [selectedProvider, setSelectedProvider] = useState<
"text-provider" | "image-provider" | "privacy" | "session"
>("text-provider");
const userConfigState = useSelector((state: RootState) => state.userConfig);
const [llmConfig, setLlmConfig] = useState<LLMConfig>(
userConfigState.llm_config
@ -414,36 +417,52 @@ const SettingsPage = () => {
/>}
{mode === 'presenton' && selectedProvider === 'image-provider' && <ImageProvider llmConfig={llmConfig} setLlmConfig={setLlmConfig} />}
{selectedProvider === 'privacy' && <PrivacySettings />}
{selectedProvider === "session" && (
<div className="w-full max-w-lg space-y-5 rounded-[20px] border border-[#EDEEEF] bg-white p-7">
<div>
<h4 className="font-unbounded text-lg font-normal text-black">Sign out</h4>
<p className="mt-2 font-syne text-sm leading-relaxed text-[#494A4D]">
End your session on this deployment. You will need to sign in again to use the app and access the API.
</p>
</div>
<LogoutButton
label="Sign out"
className="inline-flex w-full items-center justify-center gap-2 rounded-[58px] border border-[#EDEEEF] bg-[#7C51F8] px-5 py-3 font-syne text-xs font-semibold text-white transition hover:bg-[#6d46e6] disabled:cursor-not-allowed disabled:opacity-60"
/>
</div>
)}
</div>
</main>
{/* Fixed Bottom Button */}
<div className=" mx-auto fixed bottom-20 right-5 ">
<button
onClick={handleSaveConfig}
disabled={buttonState.isDisabled}
style={{
background:
"linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
color: "#101323",
}}
className={`w-full font-syne font-semibold flex items-center justify-center gap-2 py-3 px-5 rounded-[58px] transition-all duration-500 ${buttonState.isDisabled
? "bg-gray-400 cursor-not-allowed"
: "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200"
} text-white`}
>
{buttonState.isLoading ? (
<div className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{buttonState.text}
</div>
) : (
buttonState.text
)}
<ChevronRight className="w-4 h-4" />
</button>
</div>
{/* Fixed Bottom Button — hidden on Sign out; nothing to save there */}
{selectedProvider !== "session" ? (
<div className=" mx-auto fixed bottom-20 right-5 ">
<button
onClick={handleSaveConfig}
disabled={buttonState.isDisabled}
style={{
background:
"linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
color: "#101323",
}}
className={`w-full font-syne font-semibold flex items-center justify-center gap-2 py-3 px-5 rounded-[58px] transition-all duration-500 ${buttonState.isDisabled
? "bg-gray-400 cursor-not-allowed"
: "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200"
} text-white`}
>
{buttonState.isLoading ? (
<div className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{buttonState.text}
</div>
) : (
buttonState.text
)}
<ChevronRight className="w-4 h-4" />
</button>
</div>
) : null}
{/* Download Progress Modal */}
{showDownloadModal && downloadingModel && (

View file

@ -1,10 +1,12 @@
import React from 'react'
import { Shield } from 'lucide-react'
import { LogOut, Shield } from 'lucide-react'
import { IMAGE_PROVIDERS, LLM_PROVIDERS } from '@/utils/providerConstants'
import { useSelector } from 'react-redux'
import { RootState } from '@/store/store'
const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }: { mode: 'nanobanana' | 'presenton', setMode: (mode: 'nanobanana' | 'presenton') => void, selectedProvider: 'text-provider' | 'image-provider' | 'privacy', setSelectedProvider: (provider: 'text-provider' | 'image-provider' | 'privacy') => void }) => {
type SettingsSection = 'text-provider' | 'image-provider' | 'privacy' | 'session'
const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }: { mode: 'nanobanana' | 'presenton', setMode: (mode: 'nanobanana' | 'presenton') => void, selectedProvider: SettingsSection, setSelectedProvider: (provider: SettingsSection) => void }) => {
const { llm_config } = useSelector((state: RootState) => state.userConfig)
const textProviderIcon = LLM_PROVIDERS[llm_config.LLM as keyof typeof LLM_PROVIDERS]?.icon
const imageProviderIcon = IMAGE_PROVIDERS[llm_config.IMAGE_PROVIDER as keyof typeof IMAGE_PROVIDERS]?.icon || '/providers/pexel.png'
@ -73,15 +75,26 @@ const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }
<div className='border-t border-[#E1E1E5] py-5 relative z-50'>
<p className='text-[#3A3A3A] text-xs font-medium pb-2.5'>Other</p>
<button
className={`w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border ${selectedProvider === 'privacy' ? 'bg-[#F4F3FF] border-[#D9D6FE]' : 'bg-white border-[#EDEEEF]'}`}
onClick={() => setSelectedProvider('privacy')}
>
<div className='relative w-6 h-6 rounded-full overflow-hidden border border-[#EDEEEF] flex items-center justify-center bg-white'>
<Shield className='w-3.5 h-3.5 text-[#5146E5]' />
</div>
<p className='text-[#191919] text-xs font-medium'>Usage Analytics</p>
</button>
<div className='space-y-2.5'>
<button
className={`w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border ${selectedProvider === 'privacy' ? 'bg-[#F4F3FF] border-[#D9D6FE]' : 'bg-white border-[#EDEEEF]'}`}
onClick={() => setSelectedProvider('privacy')}
>
<div className='relative w-6 h-6 rounded-full overflow-hidden border border-[#EDEEEF] flex items-center justify-center bg-white'>
<Shield className='w-3.5 h-3.5 text-[#5146E5]' />
</div>
<p className='text-[#191919] text-xs font-medium'>Usage Analytics</p>
</button>
<button
className={`w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border ${selectedProvider === 'session' ? 'bg-[#F4F3FF] border-[#D9D6FE]' : 'bg-white border-[#EDEEEF]'}`}
onClick={() => setSelectedProvider('session')}
>
<div className='relative w-6 h-6 rounded-full overflow-hidden border border-[#EDEEEF] flex items-center justify-center bg-white'>
<LogOut className='w-3.5 h-3.5 text-[#5146E5]' />
</div>
<p className='text-[#191919] text-xs font-medium'>Sign out</p>
</button>
</div>
</div>
</div>
)

View file

@ -1,13 +1,16 @@
import React from 'react'
import { ConfigurationInitializer } from '../ConfigurationInitializer'
import React from "react";
import ProtectedRouteGuard from "@/components/Auth/ProtectedRouteGuard";
import { ConfigurationInitializer } from "../ConfigurationInitializer";
const layout = ({ children }: { children: React.ReactNode }) => {
return (
<div>
<ConfigurationInitializer>
{children}
</ConfigurationInitializer>
<ProtectedRouteGuard>
<ConfigurationInitializer>{children}</ConfigurationInitializer>
</ProtectedRouteGuard>
</div>
)
}
);
};
export default layout
export default layout;

View file

@ -4,6 +4,25 @@ import { LLMConfig } from "@/types/llm_config";
const userConfigPath = process.env.USER_CONFIG_PATH!;
const canChangeKeys = process.env.CAN_CHANGE_KEYS !== "false";
const AUTH_FIELDS = new Set([
"AUTH_USERNAME",
"AUTH_PASSWORD_HASH",
"AUTH_SECRET_KEY",
]);
function stripAuthFields(config: Record<string, unknown>) {
const sanitized = { ...config };
for (const key of AUTH_FIELDS) {
delete sanitized[key];
}
return sanitized;
}
function stripAuthFieldsFromIncoming(config: Record<string, unknown>) {
return Object.fromEntries(
Object.entries(config).filter(([key]) => !AUTH_FIELDS.has(key))
);
}
export async function GET() {
if (!canChangeKeys) {
@ -23,7 +42,8 @@ export async function GET() {
return NextResponse.json({});
}
const configData = fs.readFileSync(userConfigPath, "utf-8");
return NextResponse.json(JSON.parse(configData));
const parsedConfig = JSON.parse(configData) as Record<string, unknown>;
return NextResponse.json(stripAuthFields(parsedConfig));
}
export async function POST(request: Request) {
@ -33,9 +53,9 @@ export async function POST(request: Request) {
});
}
const userConfig = await request.json();
console.log('userConfig', userConfig);
const userConfig = stripAuthFieldsFromIncoming(
(await request.json()) as Record<string, unknown>
) as LLMConfig;
let existingConfig: LLMConfig = {};
if (fs.existsSync(userConfigPath)) {
const configData = fs.readFileSync(userConfigPath, "utf-8");
@ -78,5 +98,7 @@ export async function POST(request: Request) {
: existingConfig.DISABLE_ANONYMOUS_TRACKING,
};
fs.writeFileSync(userConfigPath, JSON.stringify(mergedConfig));
return NextResponse.json(mergedConfig);
return NextResponse.json(
stripAuthFields(mergedConfig as Record<string, unknown>)
);
}

View file

@ -1,9 +1,7 @@
import Home from "@/components/Home"
import AuthGate from "@/components/Auth/AuthGate";
const page = () => {
return (
<Home />
)
}
return <AuthGate />;
};
export default page
export default page;

View file

@ -0,0 +1,304 @@
"use client";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { ConfigurationInitializer } from "@/app/ConfigurationInitializer";
import Home from "@/components/Home";
import { getApiUrl } from "@/utils/api";
import { formatFastApiDetail, UNAUTHORIZED_DETAIL } from "@/utils/authErrors";
type AuthStatus = {
configured: boolean;
authenticated: boolean;
username: string | null;
};
const initialStatus: AuthStatus = {
configured: false,
authenticated: false,
username: null,
};
export default function AuthGate() {
const [status, setStatus] = useState<AuthStatus>(initialStatus);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [blockedAccessMessage, setBlockedAccessMessage] = useState<string | null>(null);
const isSetupMode = useMemo(() => !status.configured, [status.configured]);
useEffect(() => {
void refreshStatus();
}, []);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const params = new URLSearchParams(window.location.search);
if (params.get("reason") === "unauthorized") {
setBlockedAccessMessage(UNAUTHORIZED_DETAIL);
const clean = `${window.location.pathname}`;
window.history.replaceState({}, "", clean);
}
}, []);
const refreshStatus = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(getApiUrl("/api/v1/auth/status"), {
method: "GET",
cache: "no-store",
credentials: "include",
});
if (!response.ok) {
throw new Error("Could not load login state");
}
const data = (await response.json()) as AuthStatus;
setStatus({
configured: Boolean(data.configured),
authenticated: Boolean(data.authenticated),
username: data.username ?? null,
});
} catch (fetchError) {
console.error(fetchError);
setError("Could not connect to the login service. Please refresh and try again.");
} finally {
setIsLoading(false);
}
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
setBlockedAccessMessage(null);
const cleanedUsername = username.trim();
if (cleanedUsername.length < 3) {
setError("Username must be at least 3 characters.");
return;
}
if (password.length < 6) {
setError("Password must be at least 6 characters.");
return;
}
if (isSetupMode && password !== confirmPassword) {
setError("Password confirmation does not match.");
return;
}
setIsSubmitting(true);
try {
const response = await fetch(
getApiUrl(isSetupMode ? "/api/v1/auth/setup" : "/api/v1/auth/login"),
{
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: cleanedUsername,
password,
}),
}
);
const payload = await response.json();
if (!response.ok) {
const detail = formatFastApiDetail(payload?.detail);
if (response.status === 401) {
setError(detail === UNAUTHORIZED_DETAIL ? UNAUTHORIZED_DETAIL : detail);
} else {
setError(detail || "Login failed. Please try again.");
}
return;
}
setStatus({
configured: Boolean(payload.configured),
authenticated: Boolean(payload.authenticated),
username: payload.username ?? cleanedUsername,
});
setPassword("");
setConfirmPassword("");
} catch (submitError) {
console.error(submitError);
setError("Login service is unavailable. Please try again in a moment.");
} finally {
setIsSubmitting(false);
}
};
if (isLoading) {
return (
<main className="relative min-h-screen overflow-hidden bg-gradient-to-br from-[#E9E8F8] via-[#F5F4FF] to-[#E0DFF7] flex items-center justify-center p-6">
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-[45%] opacity-90"
style={{
background:
"radial-gradient(50% 50% at 50% 100%, rgba(122, 90, 248, 0.35) 0%, rgba(122, 90, 248, 0) 70%)",
}}
/>
<div className="relative z-10 w-full max-w-md">
<div className="rounded-2xl border border-white/40 bg-white/80 p-8 text-center shadow-xl backdrop-blur-sm">
<img src="/Logo.png" alt="Presenton" className="mx-auto mb-5 h-12 opacity-95" />
<div className="mx-auto mb-4 h-1 w-16 rounded-full bg-gradient-to-r from-[#5146E5] to-[#7C51F8]" />
<h1 className="font-unbounded text-lg font-semibold text-black">Presenton</h1>
<p className="mt-3 font-syne text-sm text-[#000000CC]">Preparing your workspace</p>
<div className="mt-6 flex justify-center gap-1.5">
<span className="h-2 w-2 animate-pulse rounded-full bg-[#5146E5]" />
<span
className="h-2 w-2 animate-pulse rounded-full bg-[#7C51F8]"
style={{ animationDelay: "0.2s" }}
/>
<span
className="h-2 w-2 animate-pulse rounded-full bg-[#5146E5]"
style={{ animationDelay: "0.4s" }}
/>
</div>
</div>
</div>
</main>
);
}
if (status.authenticated) {
return (
<ConfigurationInitializer>
<Home />
</ConfigurationInitializer>
);
}
return (
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-gradient-to-br from-[#E9E8F8] via-[#F5F4FF] to-[#E0DFF7] p-6">
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-[50%] opacity-95"
style={{
background:
"radial-gradient(50% 50% at 50% 100%, rgba(122, 90, 248, 0.45) 0%, rgba(122, 90, 248, 0) 72%)",
}}
/>
<div className="pointer-events-none absolute -right-32 -top-32 h-[380px] w-[380px] rounded-full bg-[#7C51F8]/20 blur-3xl" />
<div className="pointer-events-none absolute -bottom-40 -left-32 h-[420px] w-[420px] rounded-full bg-[#5146E5]/15 blur-3xl" />
<section className="relative z-10 w-full max-w-xl rounded-2xl border border-[#E1E1E5] bg-white/90 p-7 shadow-xl backdrop-blur-sm sm:p-10">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-[74px] w-[74px] shrink-0 items-center justify-center rounded-[4px] bg-[#F4F3FF] p-3">
<img src="/logo-with-bg.png" alt="" className="h-10 w-10 object-contain" />
</div>
<div>
<p className="font-syne text-[10px] font-semibold uppercase tracking-[0.14em] text-[#7A5AF8]">
Secure instance
</p>
<h1 className="mt-1 font-unbounded text-2xl font-normal leading-tight text-black sm:text-[26px]">
{isSetupMode ? "Create your admin login" : "Sign in to continue"}
</h1>
</div>
</div>
</div>
<p className="font-syne text-base text-[#000000CC] sm:text-lg">
{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."}
</p>
{blockedAccessMessage ? (
<div className="mt-6 rounded-[11px] border border-red-200 bg-red-50 px-4 py-3 font-syne text-sm font-medium text-red-800">
{blockedAccessMessage}. All other routes require a valid session.
</div>
) : null}
<form onSubmit={handleSubmit} className="mt-8 space-y-5">
<div className="space-y-2">
<label htmlFor="username" className="block font-syne text-sm font-medium text-black">
Username
</label>
<input
id="username"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="your-admin-user"
className="w-full rounded-[11px] border border-[#EDEEEF] bg-white px-4 py-3 font-syne text-sm text-black outline-none transition placeholder:text-[#999999] focus:border-[#a49cfc] focus:ring-2 focus:ring-[#5146E5]/20"
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="block font-syne text-sm font-medium text-black">
Password
</label>
<input
id="password"
type="password"
autoComplete={isSetupMode ? "new-password" : "current-password"}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="At least 6 characters"
className="w-full rounded-[11px] border border-[#EDEEEF] bg-white px-4 py-3 font-syne text-sm text-black outline-none transition placeholder:text-[#999999] focus:border-[#a49cfc] focus:ring-2 focus:ring-[#5146E5]/20"
disabled={isSubmitting}
/>
</div>
{isSetupMode ? (
<div className="space-y-2">
<label htmlFor="confirmPassword" className="block font-syne text-sm font-medium text-black">
Confirm password
</label>
<input
id="confirmPassword"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
placeholder="Re-enter your password"
className="w-full rounded-[11px] border border-[#EDEEEF] bg-white px-4 py-3 font-syne text-sm text-black outline-none transition placeholder:text-[#999999] focus:border-[#a49cfc] focus:ring-2 focus:ring-[#5146E5]/20"
disabled={isSubmitting}
/>
</div>
) : null}
{error ? (
<div className="rounded-[11px] border border-red-200 bg-red-50 px-4 py-3 font-syne text-sm text-red-800">
{error}
</div>
) : null}
{!isSetupMode && status.configured ? (
<p className="font-syne text-sm text-[#494A4D]">
Setup is complete for this instance. Use the username and password you configured.
</p>
) : null}
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-[58px] border border-[#EDEEEF] bg-[#7C51F8] px-5 py-3 font-syne text-xs font-semibold text-white transition hover:bg-[#6d46e6] disabled:cursor-not-allowed disabled:opacity-60"
>
{isSubmitting
? isSetupMode
? "Saving credentials…"
: "Signing in…"
: isSetupMode
? "Create login and continue"
: "Sign in"}
</button>
</form>
</section>
</main>
);
}

View file

@ -0,0 +1,56 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { LogOut } from "lucide-react";
import { getApiUrl } from "@/utils/api";
type LogoutButtonProps = {
label?: string;
className?: string;
iconOnly?: boolean;
};
export default function LogoutButton({
label = "Logout",
className = "",
iconOnly = false,
}: LogoutButtonProps) {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const handleLogout = async () => {
if (isSubmitting) {
return;
}
setIsSubmitting(true);
try {
await fetch(getApiUrl("/api/v1/auth/logout"), {
method: "POST",
credentials: "include",
});
} catch {
// Always route back to auth gate even if backend logout fails.
} finally {
router.push("/");
router.refresh();
setIsSubmitting(false);
}
};
return (
<button
type="button"
onClick={handleLogout}
disabled={isSubmitting}
className={className}
aria-label={label}
title={label}
>
<LogOut className="h-4 w-4" />
{!iconOnly ? <span>{isSubmitting ? "Signing out..." : label}</span> : null}
</button>
);
}

View file

@ -0,0 +1,77 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { getApiUrl } from "@/utils/api";
/**
* Defense in depth: if a protected page ever renders without a valid session
* (stale tab, manual history navigation, etc.), send the user back to the
* login screen. Edge middleware is the primary gate; this catches client-only
* edge cases.
*/
export default function ProtectedRouteGuard({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const [allowed, setAllowed] = useState(false);
useEffect(() => {
let cancelled = false;
const verify = async () => {
try {
const response = await fetch(getApiUrl("/api/v1/auth/status"), {
method: "GET",
credentials: "include",
cache: "no-store",
});
if (!response.ok || cancelled) {
if (!cancelled) {
router.replace("/?reason=unauthorized");
}
return;
}
const data = (await response.json()) as {
authenticated?: boolean;
};
if (cancelled) {
return;
}
if (!data.authenticated) {
router.replace("/?reason=unauthorized");
return;
}
setAllowed(true);
} catch {
if (!cancelled) {
router.replace("/?reason=unauthorized");
}
}
};
void verify();
return () => {
cancelled = true;
};
}, [pathname, router]);
if (!allowed) {
return (
<div className="flex min-h-[40vh] items-center justify-center bg-gradient-to-br from-[#E9E8F8] via-[#F5F4FF] to-[#E0DFF7] font-syne text-sm text-[#494A4D]">
Verifying session
</div>
);
}
return <>{children}</>;
}

View file

@ -1,45 +1,46 @@
import React from 'react'
const OnBoardingHeader = ({ currentStep, setStep }: { currentStep: number, setStep: (step: number) => void }) => {
return (
<div className='sticky top-8 z-20 flex items-center font-syne justify-end gap-1 mt-7 mb-[52px]'>
<div className='flex items-center gap-1 cursor-pointer'
onClick={() => {
if (currentStep > 1) {
setStep(1);
}
}}
>
<div className={`${currentStep === 1 ? 'bg-[#010100] text-white' : 'border border-[#ECECEF] text-[#494A4D]'} px-2.5 h-7 w-7 text-xs font-medium rounded-full flex items-center justify-center `}>
1
</div>
<p className='text-[#010000] text-xs '>Select Mode</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="1" viewBox="0 0 22 1" fill="none">
<path d="M0 0.5H21.5" stroke="#ECECEF" />
</svg>
<div className='flex items-center gap-1 cursor-pointer'
onClick={() => {
if (currentStep > 2) {
setStep(2);
}
}}
>
<div className={`${currentStep === 2 ? 'bg-[#010100] text-white' : 'border border-[#ECECEF] text-[#494A4D]'} px-2.5 h-7 w-7 text-xs font-medium rounded-full flex items-center justify-center `}>
2
</div>
<p className='text-[#010000] text-xs '>Choose Providers</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="1" viewBox="0 0 22 1" fill="none">
<path d="M0 0.5H21.5" stroke="#ECECEF" />
</svg>
<div className='flex items-center gap-1'>
<div className={`${currentStep === 3 ? 'bg-[#010100] text-white' : 'border border-[#ECECEF] text-[#494A4D]'} px-2.5 h-7 w-7 text-xs font-medium rounded-full flex items-center justify-center `}>
3
<div className='flex items-center gap-1 cursor-pointer'
onClick={() => {
if (currentStep > 1) {
setStep(1);
}
}}
>
<div className={`${currentStep === 1 ? 'bg-[#010100] text-white' : 'border border-[#ECECEF] text-[#494A4D]'} px-2.5 h-7 w-7 text-xs font-medium rounded-full flex items-center justify-center `}>
1
</div>
<p className='text-[#010000] text-xs '>Select Mode</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="1" viewBox="0 0 22 1" fill="none">
<path d="M0 0.5H21.5" stroke="#ECECEF" />
</svg>
<div className='flex items-center gap-1 cursor-pointer'
onClick={() => {
if (currentStep > 2) {
setStep(2);
}
}}
>
<div className={`${currentStep === 2 ? 'bg-[#010100] text-white' : 'border border-[#ECECEF] text-[#494A4D]'} px-2.5 h-7 w-7 text-xs font-medium rounded-full flex items-center justify-center `}>
2
</div>
<p className='text-[#010000] text-xs '>Choose Providers</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="1" viewBox="0 0 22 1" fill="none">
<path d="M0 0.5H21.5" stroke="#ECECEF" />
</svg>
<div className='flex items-center gap-1'>
<div className={`${currentStep === 3 ? 'bg-[#010100] text-white' : 'border border-[#ECECEF] text-[#494A4D]'} px-2.5 h-7 w-7 text-xs font-medium rounded-full flex items-center justify-center `}>
3
</div>
<p className='text-[#010000] text-xs '>Finish Setup</p>
</div>
<p className='text-[#010000] text-xs '>Finish Setup</p>
</div>
</div>
)

View file

@ -0,0 +1,147 @@
import { NextRequest, NextResponse } from "next/server";
const PUBLIC_PATHS = new Set([
"/",
"/favicon.ico",
"/apple-icon.png",
"/icon1.svg",
"/icon2.png",
"/api/telemetry-status",
]);
const PUBLIC_PREFIXES = ["/_next/", "/api/v1/auth/"];
/**
* Build the URL the browser used to reach the app. When nginx proxies to
* Next on :3000, `request.nextUrl.origin` is often `http://localhost:3000`
* (wrong for redirects). Prefer reverse-proxy headers instead.
*/
function getExternalOrigin(request: NextRequest): string {
const xfHost =
request.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ?? "";
if (xfHost) {
const xfProto =
request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim().toLowerCase() ??
"";
const proto =
xfProto === "https" || xfProto === "http" ? xfProto : "http";
return `${proto}://${xfHost}`;
}
return request.nextUrl.origin;
}
function isPublicRequest(pathname: string): boolean {
if (PUBLIC_PATHS.has(pathname)) {
return true;
}
if (PUBLIC_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
return true;
}
// Allow requests for static files in /public.
if (/\.[a-zA-Z0-9]+$/.test(pathname)) {
return true;
}
return false;
}
function getFastApiBaseUrl(request: NextRequest): string {
// Server-side-only override. Used by the Docker runtime so the Next.js
// middleware can reach FastAPI directly inside the container (nginx's
// port is not reachable from inside the Next.js process).
const internal = process.env.FAST_API_INTERNAL_URL?.trim();
if (internal) {
return internal.replace(/\/+$/, "");
}
const configured = process.env.NEXT_PUBLIC_FAST_API?.trim();
if (configured) {
return configured.replace(/\/+$/, "");
}
if (process.env.NODE_ENV === "development") {
return "http://127.0.0.1:8000";
}
// Fallback: reuse the incoming origin (works when Next.js and FastAPI
// are served from the same origin, e.g. behind nginx on the same host).
return request.nextUrl.origin;
}
type AuthStatus = {
configured: boolean;
authenticated: boolean;
};
async function getAuthStatus(request: NextRequest): Promise<AuthStatus> {
const cookieHeader = request.headers.get("cookie");
const authStatusUrl = `${getFastApiBaseUrl(request)}/api/v1/auth/status`;
try {
const response = await fetch(authStatusUrl, {
method: "GET",
headers: cookieHeader ? { Cookie: cookieHeader } : undefined,
cache: "no-store",
});
if (!response.ok) {
return {
configured: true,
authenticated: false,
};
}
const payload = (await response.json()) as Partial<AuthStatus>;
return {
configured: Boolean(payload.configured),
authenticated: Boolean(payload.authenticated),
};
} catch {
return {
configured: true,
authenticated: false,
};
}
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (request.method === "OPTIONS" || isPublicRequest(pathname)) {
return NextResponse.next();
}
const authStatus = await getAuthStatus(request);
if (authStatus.authenticated) {
return NextResponse.next();
}
if (pathname.startsWith("/api/")) {
const statusCode = authStatus.configured ? 401 : 428;
const detail = authStatus.configured
? "Unauthorized"
: "Login setup is required";
return NextResponse.json(
{ detail },
{
status: statusCode,
headers: {
"Cache-Control": "no-store, must-revalidate",
},
}
);
}
const redirectUrl = new URL("/", getExternalOrigin(request));
if (pathname !== "/") {
redirectUrl.searchParams.set("reason", "unauthorized");
}
return NextResponse.redirect(redirectUrl);
}
export const config = {
matcher: ["/:path*"],
};

View file

@ -0,0 +1,22 @@
/** Matches FastAPI `HTTPException(detail=...)` and JSON error bodies. */
export const UNAUTHORIZED_DETAIL = "Unauthorized";
export function formatFastApiDetail(detail: unknown): string {
if (typeof detail === "string") {
return detail;
}
if (Array.isArray(detail)) {
return detail
.map((item) => {
if (item && typeof item === "object" && "msg" in item) {
return String((item as { msg?: string }).msg ?? item);
}
return String(item);
})
.join(" ");
}
if (detail && typeof detail === "object" && "message" in detail) {
return String((detail as { message?: string }).message);
}
return UNAUTHORIZED_DETAIL;
}

View file

@ -103,6 +103,12 @@ const ensurePresentationExportRuntime = async () => {
};
process.env.USER_CONFIG_PATH = userConfigPath;
// Let Next.js middleware reach FastAPI over the loopback interface inside the
// container without having to bounce through nginx (the host-facing port is
// not reachable from inside the Next.js process).
if (!process.env.FAST_API_INTERNAL_URL) {
process.env.FAST_API_INTERNAL_URL = `http://127.0.0.1:${fastapiPort}`;
}
//? UserConfig is only setup if API Keys can be changed
const setupUserConfigFromEnv = () => {
@ -155,6 +161,9 @@ const setupUserConfigFromEnv = () => {
CODEX_REFRESH_TOKEN: existingConfig.CODEX_REFRESH_TOKEN,
CODEX_TOKEN_EXPIRES: existingConfig.CODEX_TOKEN_EXPIRES,
CODEX_ACCOUNT_ID: existingConfig.CODEX_ACCOUNT_ID,
AUTH_USERNAME: existingConfig.AUTH_USERNAME,
AUTH_PASSWORD_HASH: existingConfig.AUTH_PASSWORD_HASH,
AUTH_SECRET_KEY: existingConfig.AUTH_SECRET_KEY,
};
writeFileSync(userConfigPath, JSON.stringify(userConfig));