feat: optional Bearer-token authentication via API_TOKEN env var
Disabled by default (empty API_TOKEN). When set: - All /api/* and /mcp requests require: Authorization: Bearer <token> - Public exemptions: /, /healthz, /static/*, /auth-check - Web UI: pre-flight /auth-check on load; shows token modal if required - Token stored in sessionStorage, sent on every API request - Mid-session 401s re-trigger the token modal - MCP clients must pass the header: Authorization: Bearer <token> Files changed: - app/config.py: api_token field + API_TOKEN env var - app/api/auth.py: Starlette BaseHTTPMiddleware for token enforcement - main.py: register middleware + /auth-check public endpoint - static/js/api.js: token storage, auth header, 401 handler hook - static/js/app.js: auth pre-flight, showTokenModal(), bootstrap() - static/css/components.css: .auth-overlay / .auth-card styles - README.md: API_TOKEN env var docs + MCP client header example
This commit is contained in:
18
README.md
18
README.md
@@ -492,6 +492,7 @@ Server-Sent Events endpoint for live UI updates.
|
|||||||
- [x] MCP stateless/stateful mode configurable via `MCP_STATELESS` env var (default `true`).
|
- [x] MCP stateless/stateful mode configurable via `MCP_STATELESS` env var (default `true`).
|
||||||
- [x] Per-agent generation counter prevents abandoned (timed-out) coroutines from silently consuming instructions meant for newer calls.
|
- [x] Per-agent generation counter prevents abandoned (timed-out) coroutines from silently consuming instructions meant for newer calls.
|
||||||
- [x] `tests/test_wakeup.py` covers both immediate-wakeup timing and concurrent-call generation safety.
|
- [x] `tests/test_wakeup.py` covers both immediate-wakeup timing and concurrent-call generation safety.
|
||||||
|
- [x] Optional Bearer-token authentication via `API_TOKEN` env var (disabled by default); web UI prompts for token on first load.
|
||||||
|
|
||||||
- [ ] **Documentation and developer experience**
|
- [ ] **Documentation and developer experience**
|
||||||
- [x] Document local run instructions.
|
- [x] Document local run instructions.
|
||||||
@@ -564,6 +565,7 @@ The server starts on `http://localhost:8000` by default.
|
|||||||
| `DEFAULT_EMPTY_RESPONSE` | _(empty)_ | Default response when queue is empty |
|
| `DEFAULT_EMPTY_RESPONSE` | _(empty)_ | Default response when queue is empty |
|
||||||
| `AGENT_STALE_AFTER_SECONDS` | `30` | Seconds of inactivity before agent shown as idle |
|
| `AGENT_STALE_AFTER_SECONDS` | `30` | Seconds of inactivity before agent shown as idle |
|
||||||
| `MCP_STATELESS` | `true` | `true` for stateless sessions (survives restarts, recommended); `false` for stateful |
|
| `MCP_STATELESS` | `true` | `true` for stateless sessions (survives restarts, recommended); `false` for stateful |
|
||||||
|
| `API_TOKEN` | _(empty)_ | When set, all `/api/*` and `/mcp` requests require `Authorization: Bearer <token>`; web UI prompts for the token on first load |
|
||||||
|
|
||||||
### Configuring an MCP client (agent)
|
### Configuring an MCP client (agent)
|
||||||
|
|
||||||
@@ -580,6 +582,22 @@ Point the agent's MCP client to the streamable-HTTP transport:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If `API_TOKEN` is set, include the token as a request header:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"local-mcp": {
|
||||||
|
"url": "http://localhost:8000/mcp",
|
||||||
|
"transport": "streamable-http",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer <your-token>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
The agent should call `get_user_request` aggressively and continuously — **do not end the working session**. Every call returns the next pending instruction (if any). When the queue is empty the tool waits up to `wait_seconds` before returning an empty/default response, so the agent should loop and call again.
|
The agent should call `get_user_request` aggressively and continuously — **do not end the working session**. Every call returns the next pending instruction (if any). When the queue is empty the tool waits up to `wait_seconds` before returning an empty/default response, so the agent should loop and call again.
|
||||||
|
|
||||||
## 10. Implementation Notes for Future Work
|
## 10. Implementation Notes for Future Work
|
||||||
|
|||||||
67
app/api/auth.py
Normal file
67
app/api/auth.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
app/api/auth.py
|
||||||
|
Optional Bearer-token authentication middleware.
|
||||||
|
|
||||||
|
When API_TOKEN is set in the environment, every request to a protected path
|
||||||
|
must include the header:
|
||||||
|
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
|
||||||
|
Public paths (no auth required even when a token is configured):
|
||||||
|
GET / (serves index.html — UI bootstrap)
|
||||||
|
GET /healthz (health check)
|
||||||
|
GET /static/* (JS / CSS / fonts — UI assets)
|
||||||
|
GET /auth-check (lets the UI know whether auth is required)
|
||||||
|
|
||||||
|
All other paths — /api/*, /mcp, /docs, /openapi.json — are protected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Paths that are always accessible without a token
|
||||||
|
_PUBLIC_EXACT = frozenset({"/", "/healthz", "/auth-check"})
|
||||||
|
_PUBLIC_PREFIXES = ("/static/",)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenAuthMiddleware(BaseHTTPMiddleware):
|
||||||
|
def __init__(self, app, token: str) -> None:
|
||||||
|
super().__init__(app)
|
||||||
|
self._token = token
|
||||||
|
if token:
|
||||||
|
logger.info("Token authentication ENABLED")
|
||||||
|
else:
|
||||||
|
logger.info("Token authentication disabled (set API_TOKEN to enable)")
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
# Auth disabled → pass through
|
||||||
|
if not self._token:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
path = request.url.path
|
||||||
|
|
||||||
|
# Always-public paths
|
||||||
|
if path in _PUBLIC_EXACT:
|
||||||
|
return await call_next(request)
|
||||||
|
for prefix in _PUBLIC_PREFIXES:
|
||||||
|
if path.startswith(prefix):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Validate Bearer token
|
||||||
|
auth_header = request.headers.get("Authorization", "")
|
||||||
|
if auth_header == f"Bearer {self._token}":
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
logger.warning("Unauthorized request: %s %s", request.method, path)
|
||||||
|
return JSONResponse(
|
||||||
|
{"detail": "Unauthorized — provide a valid Bearer token"},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
@@ -33,6 +33,9 @@ class Settings:
|
|||||||
# Set MCP_STATELESS=false to use stateful sessions (needed for multi-turn MCP flows).
|
# Set MCP_STATELESS=false to use stateful sessions (needed for multi-turn MCP flows).
|
||||||
mcp_stateless: bool = True
|
mcp_stateless: bool = True
|
||||||
|
|
||||||
|
# Authentication — set API_TOKEN env var to enable; empty string disables auth entirely.
|
||||||
|
api_token: str = ""
|
||||||
|
|
||||||
|
|
||||||
def _parse_bool(value: str, default: bool) -> bool:
|
def _parse_bool(value: str, default: bool) -> bool:
|
||||||
if value.lower() in ("1", "true", "yes", "on"):
|
if value.lower() in ("1", "true", "yes", "on"):
|
||||||
@@ -54,6 +57,7 @@ def load_settings() -> Settings:
|
|||||||
agent_stale_after_seconds=int(os.getenv("AGENT_STALE_AFTER_SECONDS", "30")),
|
agent_stale_after_seconds=int(os.getenv("AGENT_STALE_AFTER_SECONDS", "30")),
|
||||||
mcp_server_name=os.getenv("MCP_SERVER_NAME", "local-mcp"),
|
mcp_server_name=os.getenv("MCP_SERVER_NAME", "local-mcp"),
|
||||||
mcp_stateless=_parse_bool(os.getenv("MCP_STATELESS", "true"), default=True),
|
mcp_stateless=_parse_bool(os.getenv("MCP_STATELESS", "true"), default=True),
|
||||||
|
api_token=os.getenv("API_TOKEN", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
9
main.py
9
main.py
@@ -16,6 +16,7 @@ from fastapi.responses import FileResponse, JSONResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from app.api import routes_config, routes_instructions, routes_status
|
from app.api import routes_config, routes_instructions, routes_status
|
||||||
|
from app.api.auth import TokenAuthMiddleware
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import init_db
|
from app.database import init_db
|
||||||
from app.logging_setup import configure_logging
|
from app.logging_setup import configure_logging
|
||||||
@@ -60,6 +61,9 @@ def create_app() -> FastAPI:
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Token auth middleware (no-op when API_TOKEN is not set) ---
|
||||||
|
app.add_middleware(TokenAuthMiddleware, token=settings.api_token)
|
||||||
|
|
||||||
# --- Global exception handler ---
|
# --- Global exception handler ---
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def global_exception_handler(request: Request, exc: Exception):
|
async def global_exception_handler(request: Request, exc: Exception):
|
||||||
@@ -69,6 +73,11 @@ def create_app() -> FastAPI:
|
|||||||
content={"detail": "Internal server error", "error": str(exc)},
|
content={"detail": "Internal server error", "error": str(exc)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Public: lets the UI know whether it needs a token (no auth required) ---
|
||||||
|
@app.get("/auth-check", include_in_schema=False)
|
||||||
|
def auth_check():
|
||||||
|
return {"auth_required": bool(settings.api_token)}
|
||||||
|
|
||||||
# --- API routers ---
|
# --- API routers ---
|
||||||
app.include_router(routes_status.router)
|
app.include_router(routes_status.router)
|
||||||
app.include_router(routes_instructions.router)
|
app.include_router(routes_instructions.router)
|
||||||
|
|||||||
@@ -480,3 +480,83 @@
|
|||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Auth overlay ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.auth-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.72);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
margin: var(--space-4);
|
||||||
|
padding: var(--space-8);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: 0 32px 64px rgba(0, 0, 0, 0.48);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card__icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--cyan) 10%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--cyan) 25%, transparent);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card__title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card__desc {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card__desc code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--cyan);
|
||||||
|
background: color-mix(in srgb, var(--cyan) 10%, transparent);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card__form {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card__error {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--red);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,52 @@
|
|||||||
/**
|
/**
|
||||||
* static/js/api.js
|
* static/js/api.js
|
||||||
* Thin fetch wrappers for all HTTP API endpoints.
|
* Thin fetch wrappers for all HTTP API endpoints.
|
||||||
|
* Supports optional Bearer-token authentication.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const BASE = '';
|
const BASE = '';
|
||||||
|
const TOKEN_KEY = 'local-mcp-token';
|
||||||
|
|
||||||
|
// Callback invoked when a 401 is received — set by app.js
|
||||||
|
let _onUnauthorized = null;
|
||||||
|
|
||||||
|
export function setUnauthorizedHandler(fn) {
|
||||||
|
_onUnauthorized = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredToken() {
|
||||||
|
return sessionStorage.getItem(TOKEN_KEY) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredToken(token) {
|
||||||
|
if (token) {
|
||||||
|
sessionStorage.setItem(TOKEN_KEY, token);
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function request(method, path, body) {
|
async function request(method, path, body) {
|
||||||
const opts = {
|
const opts = {
|
||||||
method,
|
method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const token = getStoredToken();
|
||||||
|
if (token) {
|
||||||
|
opts.headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||||
|
|
||||||
const res = await fetch(BASE + path, opts);
|
const res = await fetch(BASE + path, opts);
|
||||||
if (res.status === 204) return null;
|
if (res.status === 204) return null;
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
if (_onUnauthorized) _onUnauthorized();
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const msg = data?.detail || `HTTP ${res.status}`;
|
const msg = data?.detail || `HTTP ${res.status}`;
|
||||||
@@ -27,6 +59,9 @@ export const api = {
|
|||||||
// Health
|
// Health
|
||||||
health: () => request('GET', '/healthz'),
|
health: () => request('GET', '/healthz'),
|
||||||
|
|
||||||
|
// Auth check (public — no token needed)
|
||||||
|
authCheck: () => fetch('/auth-check').then(r => r.json()),
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
status: () => request('GET', '/api/status'),
|
status: () => request('GET', '/api/status'),
|
||||||
|
|
||||||
@@ -41,4 +76,3 @@ export const api = {
|
|||||||
getConfig: () => request('GET', '/api/config'),
|
getConfig: () => request('GET', '/api/config'),
|
||||||
updateConfig: (patch) => request('PATCH', '/api/config', patch),
|
updateConfig: (patch) => request('PATCH', '/api/config', patch),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* and exports shared utilities like `toast`.
|
* and exports shared utilities like `toast`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from './api.js';
|
import { api, setUnauthorizedHandler, getStoredToken, setStoredToken } from './api.js';
|
||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { connectSSE } from './events.js';
|
import { connectSSE } from './events.js';
|
||||||
import { initInstructions, initComposer, refreshTimestamps } from './instructions.js';
|
import { initInstructions, initComposer, refreshTimestamps } from './instructions.js';
|
||||||
@@ -26,6 +26,73 @@ export function toast(message, type = 'info') {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Token auth modal ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showTokenModal(onSuccess) {
|
||||||
|
// Remove any existing modal
|
||||||
|
document.getElementById('auth-modal')?.remove();
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'auth-modal';
|
||||||
|
overlay.className = 'auth-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="auth-card fade-in">
|
||||||
|
<div class="auth-card__icon">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--cyan)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="auth-card__title">Authentication Required</h2>
|
||||||
|
<p class="auth-card__desc">This server requires a Bearer token.<br>Enter the value of <code>API_TOKEN</code> to continue.</p>
|
||||||
|
<form id="auth-form" class="auth-card__form">
|
||||||
|
<input
|
||||||
|
id="auth-token-input"
|
||||||
|
type="password"
|
||||||
|
class="input"
|
||||||
|
placeholder="Enter your API token…"
|
||||||
|
autocomplete="current-password"
|
||||||
|
spellcheck="false"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn btn--primary" style="width:100%">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
|
Unlock
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p id="auth-error" class="auth-card__error" style="display:none">Incorrect token — try again.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const form = overlay.querySelector('#auth-form');
|
||||||
|
const input = overlay.querySelector('#auth-token-input');
|
||||||
|
const errorMsg = overlay.querySelector('#auth-error');
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const token = input.value.trim();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
// Optimistically store and try a quick auth ping
|
||||||
|
setStoredToken(token);
|
||||||
|
try {
|
||||||
|
await api.status();
|
||||||
|
overlay.remove();
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
// 401 means wrong token
|
||||||
|
setStoredToken('');
|
||||||
|
input.value = '';
|
||||||
|
input.focus();
|
||||||
|
errorMsg.style.display = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Document title badge ──────────────────────────────────────────────────
|
// ── Document title badge ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function updateTitle(instructions) {
|
function updateTitle(instructions) {
|
||||||
@@ -43,7 +110,6 @@ function initReconnectingIndicator() {
|
|||||||
serverLed.className = 'led led--amber led--pulse';
|
serverLed.className = 'led led--amber led--pulse';
|
||||||
serverLed.querySelector('.led__label').textContent = 'Reconnecting…';
|
serverLed.querySelector('.led__label').textContent = 'Reconnecting…';
|
||||||
}
|
}
|
||||||
// The full status update (onopen) will re-set the correct class
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,8 +158,8 @@ function initGlobalSubscriptions() {
|
|||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
async function bootstrap() {
|
||||||
initTheme(); // must run first – sets button icon
|
initTheme();
|
||||||
initReconnectingIndicator();
|
initReconnectingIndicator();
|
||||||
initStatus();
|
initStatus();
|
||||||
initConfig();
|
initConfig();
|
||||||
@@ -101,8 +167,30 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
initComposer();
|
initComposer();
|
||||||
initGlobalSubscriptions();
|
initGlobalSubscriptions();
|
||||||
|
|
||||||
|
// Hook 401 handler so mid-session token expiry shows the modal again
|
||||||
|
setUnauthorizedHandler(() => {
|
||||||
|
showTokenModal(async () => {
|
||||||
|
await loadInitialData();
|
||||||
|
connectSSE();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check whether auth is required before doing any authenticated calls
|
||||||
|
const authInfo = await api.authCheck().catch(() => ({ auth_required: false }));
|
||||||
|
|
||||||
|
if (authInfo.auth_required && !getStoredToken()) {
|
||||||
|
// Show modal before loading data
|
||||||
|
showTokenModal(async () => {
|
||||||
await loadInitialData();
|
await loadInitialData();
|
||||||
connectSSE();
|
connectSSE();
|
||||||
startPolling();
|
startPolling();
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// No auth, or token already stored — proceed normally
|
||||||
|
await loadInitialData();
|
||||||
|
connectSSE();
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', bootstrap);
|
||||||
|
|||||||
Reference in New Issue
Block a user