commit
acfddad78e
29 changed files with 1384 additions and 171 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)
|
||||
|
|
|
|||
83
servers/fastapi/api/v1/auth/router.py
Normal file
83
servers/fastapi/api/v1/auth/router.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
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
|
||||
|
||||
username = body.username.strip()
|
||||
return JSONResponse(
|
||||
{
|
||||
"configured": True,
|
||||
"authenticated": False,
|
||||
"username": username,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
|
|
@ -74,11 +74,11 @@ class ImageGenerationService:
|
|||
"""
|
||||
if self.is_image_generation_disabled:
|
||||
print("Image generation is disabled. Using placeholder image.")
|
||||
return "/static/images/replaceable_template_image.png"
|
||||
return "/static/images/placeholder.jpg"
|
||||
|
||||
if not self.image_gen_func:
|
||||
print("No image generation function found. Using placeholder image.")
|
||||
return "/static/images/replaceable_template_image.png"
|
||||
return "/static/images/placeholder.jpg"
|
||||
|
||||
image_prompt = prompt.get_image_prompt(
|
||||
with_theme=not self.is_stock_provider_selected()
|
||||
|
|
@ -112,7 +112,7 @@ class ImageGenerationService:
|
|||
|
||||
except Exception as e:
|
||||
print(f"Error generating image: {e}")
|
||||
return "/static/images/replaceable_template_image.png"
|
||||
return "/static/images/placeholder.jpg"
|
||||
|
||||
async def generate_image_openai(
|
||||
self, prompt: str, output_directory: str, model: str, quality: str
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ class TestImageGenerationService:
|
|||
result = await service.generate_image(sample_image_prompt)
|
||||
|
||||
# Should return placeholder
|
||||
assert result == "/static/images/replaceable_template_image.png"
|
||||
assert result == "/static/images/placeholder.jpg"
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
|
|
@ -221,7 +221,7 @@ class TestImageGenerationService:
|
|||
|
||||
result = await service.generate_image(sample_image_prompt)
|
||||
|
||||
assert result == "/static/images/replaceable_template_image.png"
|
||||
assert result == "/static/images/placeholder.jpg"
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
|
|
@ -367,7 +367,7 @@ class TestImageGenerationEndpoint:
|
|||
with patch('api.v1.ppt.endpoints.images.get_images_directory', return_value=mock_images_directory):
|
||||
with patch('api.v1.ppt.endpoints.images.ImageGenerationService') as mock_service_class:
|
||||
mock_service_instance = Mock()
|
||||
mock_service_instance.generate_image = AsyncMock(return_value="/static/images/replaceable_template_image.png")
|
||||
mock_service_instance.generate_image = AsyncMock(return_value="/static/images/placeholder.jpg")
|
||||
mock_service_class.return_value = mock_service_instance
|
||||
|
||||
response = client.get(f"/images/generate?prompt={test_prompt}")
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ def test_slide_to_html_endpoint():
|
|||
|
||||
# Use a placeholder image path (since we can't easily test with real files)
|
||||
test_data = {
|
||||
"image": "/static/images/replaceable_template_image.png",
|
||||
"image": "/static/images/placeholder.jpg",
|
||||
"xml": test_xml
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ def process_slide_add_placeholder_assets(slide: SlideModel):
|
|||
for image_path in image_paths:
|
||||
image_dict = get_dict_at_path(slide.content, image_path)
|
||||
# Use FastAPI static path for placeholder image
|
||||
image_dict["__image_url__"] = "/static/images/replaceable_template_image.png"
|
||||
image_dict["__image_url__"] = "/static/images/placeholder.jpg"
|
||||
set_dict_at_path(slide.content, image_path, image_dict)
|
||||
|
||||
for icon_path in icon_paths:
|
||||
|
|
|
|||
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,13 @@
|
|||
import React from 'react'
|
||||
import { ConfigurationInitializer } from '../ConfigurationInitializer'
|
||||
const layout = ({ children }: { children: React.ReactNode }) => {
|
||||
import React from "react";
|
||||
|
||||
import { requireAppSession } from "@/utils/serverAuth";
|
||||
import { ConfigurationInitializer } from "../ConfigurationInitializer";
|
||||
|
||||
export default async function Layout({ children }: { children: React.ReactNode }) {
|
||||
await requireAppSession();
|
||||
return (
|
||||
<div>
|
||||
<ConfigurationInitializer>
|
||||
{children}
|
||||
</ConfigurationInitializer>
|
||||
<ConfigurationInitializer>{children}</ConfigurationInitializer>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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,6 +1,6 @@
|
|||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import { Syne, Unbounded } from "next/font/google";
|
||||
import { Syne } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
import MixpanelInitializer from "./MixpanelInitializer";
|
||||
|
|
@ -22,13 +22,6 @@ const syne = Syne({
|
|||
variable: "--font-syne",
|
||||
});
|
||||
|
||||
const unbounded = Unbounded({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700", "800"],
|
||||
variable: "--font-unbounded",
|
||||
});
|
||||
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://presenton.ai"),
|
||||
title: "Presenton - Open Source AI presentation generator",
|
||||
|
|
@ -82,7 +75,7 @@ export default function RootLayout({
|
|||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${inter.variable} ${unbounded.variable} ${syne.variable} antialiased`}
|
||||
className={`${inter.variable} ${syne.variable} antialiased`}
|
||||
>
|
||||
<Providers>
|
||||
<MixpanelInitializer>
|
||||
|
|
|
|||
|
|
@ -1,39 +1,51 @@
|
|||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const NotFound = () => {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-100 text-center p-6">
|
||||
<div className="max-w-lg mx-auto bg-white shadow-md rounded-lg p-8">
|
||||
<img
|
||||
src="/404.svg"
|
||||
alt="Page not found"
|
||||
className="w-3/4 mx-auto mb-6"
|
||||
/>
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-4">
|
||||
Oops! Page Not Found
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 mb-4">
|
||||
It seems you've found a page that doesn't exist. But don't worry, every great presentation starts with a blank slide!
|
||||
</p>
|
||||
|
||||
<div className="flex justify-center space-x-4 mb-8">
|
||||
<Link href="/dashboard">
|
||||
<Button className="bg-indigo-600 text-white px-6 py-2 rounded-md hover:bg-indigo-700">
|
||||
Go to Homepage
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/contact">
|
||||
<Button className="bg-gray-600 text-white px-6 py-2 rounded-md hover:bg-gray-700">
|
||||
Contact Support
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export const metadata: Metadata = {
|
||||
title: "Page not found | Presenton",
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
/**
|
||||
* Unknown routes only. Keep the 404.svg inside a fixed max height + object-contain
|
||||
* so the illustration never scales to full-viewport (the old w-3/4-only layout could).
|
||||
*/
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-100 p-6 text-center">
|
||||
<div className="mx-auto w-full max-w-lg rounded-lg bg-white p-8 shadow-md">
|
||||
<div className="mx-auto mb-6 flex h-48 w-full max-w-[300px] items-center justify-center overflow-hidden sm:h-56 sm:max-w-sm">
|
||||
<img
|
||||
src="/404.svg"
|
||||
alt="Page not found"
|
||||
width={500}
|
||||
height={500}
|
||||
className="h-full w-full object-contain object-center"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="mb-4 font-syne text-2xl font-bold text-gray-800 sm:text-3xl">
|
||||
Oops! Page Not Found
|
||||
</h1>
|
||||
<p className="mb-4 text-base text-gray-600 sm:text-lg">
|
||||
It seems you've found a page that doesn't exist. But don't worry, every
|
||||
great presentation starts with a blank slide!
|
||||
</p>
|
||||
|
||||
<div className="mb-8 flex flex-col justify-center gap-3 sm:flex-row sm:space-x-4">
|
||||
<Link href="/dashboard" className="inline-flex sm:flex-1 sm:justify-center">
|
||||
<Button className="w-full rounded-md bg-indigo-600 px-6 py-2 text-white hover:bg-indigo-700 sm:w-auto">
|
||||
Go to Homepage
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/" className="inline-flex sm:flex-1 sm:justify-center">
|
||||
<Button className="w-full rounded-md bg-gray-600 px-6 py-2 text-white hover:bg-gray-700 sm:w-auto">
|
||||
Back to start
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
11
servers/nextjs/app/schema/layout.tsx
Normal file
11
servers/nextjs/app/schema/layout.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
import { requireAppSession } from "@/utils/serverAuth";
|
||||
|
||||
/**
|
||||
* /schema is outside the (presentation-generator) group; same session gate as the main app.
|
||||
*/
|
||||
export default async function SchemaLayout({ children }: { children: ReactNode }) {
|
||||
await requireAppSession();
|
||||
return <>{children}</>;
|
||||
}
|
||||
314
servers/nextjs/components/Auth/AuthGate.tsx
Normal file
314
servers/nextjs/components/Auth/AuthGate.tsx
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
"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";
|
||||
import { toast } from "sonner";
|
||||
|
||||
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 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") {
|
||||
toast.error("Unauthorized", {
|
||||
id: "auth-unauthorized-redirect",
|
||||
description: "Sign in to view this page.",
|
||||
duration: 5000,
|
||||
});
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
}
|
||||
}, []);
|
||||
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (isSetupMode) {
|
||||
setStatus({
|
||||
configured: true,
|
||||
authenticated: false,
|
||||
username: (payload as AuthStatus).username ?? cleanedUsername,
|
||||
});
|
||||
setPassword("");
|
||||
setConfirmPassword("");
|
||||
toast.success("Account created", {
|
||||
description: "Sign in with your new username and password to continue.",
|
||||
duration: 6000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus({
|
||||
configured: Boolean((payload as AuthStatus).configured),
|
||||
authenticated: Boolean((payload as AuthStatus).authenticated),
|
||||
username: (payload as AuthStatus).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-syne 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-syne text-2xl font-semibold 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>
|
||||
|
||||
<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 account"
|
||||
: "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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
80
servers/nextjs/middleware.ts
Normal file
80
servers/nextjs/middleware.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* API-only: session required for all /api/* except auth and telemetry.
|
||||
* Page routes are protected in server layouts (unknown URLs still 404; login uses relative redirects).
|
||||
*/
|
||||
function getFastApiBaseUrl(request: NextRequest): string {
|
||||
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";
|
||||
}
|
||||
return "http://127.0.0.1:8000";
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
function isApiAuthExempt(pathname: string): boolean {
|
||||
return (
|
||||
pathname.startsWith("/api/v1/auth/") || pathname === "/api/telemetry-status"
|
||||
);
|
||||
}
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
if (request.method === "OPTIONS" || isApiAuthExempt(pathname)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const authStatus = await getAuthStatus(request);
|
||||
if (authStatus.authenticated) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
if (!authStatus.configured) {
|
||||
return NextResponse.json(
|
||||
{ detail: "Login setup is required", setup_required: true },
|
||||
{ status: 428, headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ detail: "Unauthorized" },
|
||||
{ status: 401, headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/api/: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;
|
||||
}
|
||||
78
servers/nextjs/utils/serverAuth.ts
Normal file
78
servers/nextjs/utils/serverAuth.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type AuthStatus = {
|
||||
configured: boolean;
|
||||
authenticated: boolean;
|
||||
username: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the FastAPI base used from Next server components (same as start.js).
|
||||
*/
|
||||
function getServerFastApiBase(): string {
|
||||
const internal = process.env.FAST_API_INTERNAL_URL?.trim();
|
||||
if (internal) {
|
||||
return internal.replace(/\/+$/, "");
|
||||
}
|
||||
const fromEnv = process.env.NEXT_PUBLIC_FAST_API?.trim();
|
||||
if (fromEnv) {
|
||||
return fromEnv.replace(/\/+$/, "");
|
||||
}
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
return "http://127.0.0.1:8000";
|
||||
}
|
||||
return "http://127.0.0.1:8000";
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the same /api/v1/auth/status as the browser, using the incoming request cookies.
|
||||
* Used by server layouts so 404/unknown routes are not conflated with unauthenticated access
|
||||
* (the layout only runs for routes that exist and sit under the layout’s segment).
|
||||
*/
|
||||
export async function getServerAuthStatus(): Promise<AuthStatus> {
|
||||
const h = await headers();
|
||||
const cookie = h.get("cookie") ?? "";
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getServerFastApiBase()}/api/v1/auth/status`, {
|
||||
method: "GET",
|
||||
headers: cookie ? { cookie } : undefined,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
configured: true,
|
||||
authenticated: false,
|
||||
username: null,
|
||||
};
|
||||
}
|
||||
const data = (await response.json()) as Partial<AuthStatus>;
|
||||
return {
|
||||
configured: Boolean(data.configured),
|
||||
authenticated: Boolean(data.authenticated),
|
||||
username: data.username ?? null,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
configured: true,
|
||||
authenticated: false,
|
||||
username: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If credentials are not configured yet, send the user to `/` (setup in AuthGate).
|
||||
* If configured but not signed in, send to login with a query flag the client turns into a toast.
|
||||
*/
|
||||
export async function requireAppSession() {
|
||||
const s = await getServerAuthStatus();
|
||||
if (!s.configured) {
|
||||
redirect("/");
|
||||
}
|
||||
if (!s.authenticated) {
|
||||
redirect("/?reason=unauthorized");
|
||||
}
|
||||
}
|
||||
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