feat: implement single-user authentication with environment variable support and update FastAPI middleware for session management
This commit is contained in:
parent
ba5d51ad76
commit
effa9ad026
22 changed files with 1375 additions and 117 deletions
47
README.md
47
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).
|
||||
|
||||
<br>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
51
nginx.conf
51
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
85
servers/fastapi/api/v1/auth/router.py
Normal file
85
servers/fastapi/api/v1/auth/router.py
Normal 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
|
||||
292
servers/fastapi/utils/simple_auth.py
Normal file
292
servers/fastapi/utils/simple_auth.py
Normal 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="/",
|
||||
)
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
304
servers/nextjs/components/Auth/AuthGate.tsx
Normal file
304
servers/nextjs/components/Auth/AuthGate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
servers/nextjs/components/Auth/LogoutButton.tsx
Normal file
56
servers/nextjs/components/Auth/LogoutButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
servers/nextjs/components/Auth/ProtectedRouteGuard.tsx
Normal file
77
servers/nextjs/components/Auth/ProtectedRouteGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
147
servers/nextjs/middleware.ts
Normal file
147
servers/nextjs/middleware.ts
Normal 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*"],
|
||||
};
|
||||
22
servers/nextjs/utils/authErrors.ts
Normal file
22
servers/nextjs/utils/authErrors.ts
Normal 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;
|
||||
}
|
||||
9
start.js
9
start.js
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue