feat: base-path support (ROOT_PATH) for serving under /portal
Links, form actions, and redirects are prefixed with the gateway path prefix so the portal can be served at intervalsicu.farhoodlabs.com/portal (gateway strips /portal; FastAPI root_path generates correct URLs). Connector URL is configurable.
This commit is contained in:
@@ -43,18 +43,25 @@ def create_app(config: Config | None = None) -> FastAPI:
|
||||
client_kwargs={"scope": cfg.oidc_scopes},
|
||||
)
|
||||
|
||||
app = FastAPI(title="Intervals.icu MCP portal")
|
||||
app = FastAPI(title="Intervals.icu MCP portal", root_path=cfg.root_path)
|
||||
app.add_middleware(SessionMiddleware, secret_key=cfg.session_secret, https_only=True, same_site="lax")
|
||||
app.state.cfg = cfg
|
||||
app.state.oauth = oauth
|
||||
|
||||
def render(request, name, **ctx):
|
||||
return _TEMPLATES.TemplateResponse(request, name, {"user": current_user(request), **ctx})
|
||||
return _TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
name,
|
||||
{"user": current_user(request), "base": cfg.root_path, "mcp_url": cfg.mcp_url, **ctx},
|
||||
)
|
||||
|
||||
def redirect(path: str, status_code: int = 307):
|
||||
return RedirectResponse(f"{cfg.root_path}{path}", status_code=status_code)
|
||||
|
||||
# ----- auth ----------------------------------------------------------- #
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: dict | None = Depends(current_user)):
|
||||
return RedirectResponse("/account" if user else "/login")
|
||||
return redirect("/account" if user else "/login")
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
async def login(request: Request):
|
||||
@@ -70,7 +77,7 @@ def create_app(config: Config | None = None) -> FastAPI:
|
||||
claims = dict(token.get("userinfo") or {})
|
||||
sub = claims.get("sub")
|
||||
if not sub:
|
||||
return RedirectResponse("/login")
|
||||
return redirect("/login")
|
||||
email = claims.get("email", "")
|
||||
name = claims.get("name")
|
||||
groups = claims.get("groups") or []
|
||||
@@ -82,18 +89,18 @@ def create_app(config: Config | None = None) -> FastAPI:
|
||||
"name": name,
|
||||
"is_admin": cfg.admin_group in groups,
|
||||
}
|
||||
return RedirectResponse("/account")
|
||||
return redirect("/account")
|
||||
|
||||
@app.get("/logout")
|
||||
async def logout(request: Request):
|
||||
request.session.clear()
|
||||
return RedirectResponse("/login")
|
||||
return redirect("/login")
|
||||
|
||||
# ----- account -------------------------------------------------------- #
|
||||
@app.get("/account", response_class=HTMLResponse)
|
||||
async def account(request: Request, user: dict | None = Depends(current_user)):
|
||||
if not user:
|
||||
return RedirectResponse("/login")
|
||||
return redirect("/login")
|
||||
async with db.sessionmaker()() as session:
|
||||
record = await db.get_user(session, user["sub"])
|
||||
return render(request, "account.html", record=record)
|
||||
@@ -106,7 +113,7 @@ def create_app(config: Config | None = None) -> FastAPI:
|
||||
user: dict | None = Depends(current_user),
|
||||
):
|
||||
if not user:
|
||||
return RedirectResponse("/login")
|
||||
return redirect("/login")
|
||||
ok, message = await validate_credentials(cfg.intervals_api_base, athlete_id, api_key)
|
||||
async with db.sessionmaker()() as session:
|
||||
if ok:
|
||||
@@ -124,7 +131,7 @@ def create_app(config: Config | None = None) -> FastAPI:
|
||||
@app.get("/admin", response_class=HTMLResponse)
|
||||
async def admin(request: Request, admin_user: dict | None = Depends(require_admin)):
|
||||
if not admin_user:
|
||||
return RedirectResponse("/account")
|
||||
return redirect("/account")
|
||||
async with db.sessionmaker()() as session:
|
||||
users = await db.list_users(session)
|
||||
return render(request, "admin.html", users=users)
|
||||
@@ -135,7 +142,7 @@ def create_app(config: Config | None = None) -> FastAPI:
|
||||
admin_user: dict | None = Depends(require_admin),
|
||||
):
|
||||
if not admin_user:
|
||||
return RedirectResponse("/account")
|
||||
return redirect("/account")
|
||||
async with db.sessionmaker()() as session:
|
||||
if action == "enable":
|
||||
await db.set_enabled(session, sub, True)
|
||||
@@ -143,7 +150,7 @@ def create_app(config: Config | None = None) -> FastAPI:
|
||||
await db.set_enabled(session, sub, False)
|
||||
elif action == "delete":
|
||||
await db.delete_user(session, sub)
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
return redirect("/admin", status_code=303)
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz():
|
||||
|
||||
@@ -17,6 +17,8 @@ class Config:
|
||||
admin_group: str
|
||||
intervals_api_base: str
|
||||
oidc_scopes: str
|
||||
root_path: str # public path prefix when served behind a gateway (e.g. "/portal")
|
||||
mcp_url: str # the MCP connector URL to show users
|
||||
|
||||
|
||||
def load_config() -> Config:
|
||||
@@ -30,4 +32,6 @@ def load_config() -> Config:
|
||||
admin_group=os.environ.get("ADMIN_GROUP", "intervalsicu-mcp-admins"),
|
||||
intervals_api_base=os.environ.get("INTERVALS_API_BASE_URL", "https://intervals.icu/api/v1"),
|
||||
oidc_scopes=os.environ.get("OIDC_SCOPES", "openid email profile groups"),
|
||||
root_path=os.environ.get("ROOT_PATH", "").rstrip("/"),
|
||||
mcp_url=os.environ.get("MCP_URL", "https://intervalsicu.farhoodlabs.com/mcp"),
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</p>
|
||||
<p class="muted">Find these in Intervals.icu → Settings → Developer. Your athlete ID looks like <code>i123456</code>.</p>
|
||||
|
||||
<form method="post" action="/account">
|
||||
<form method="post" action="{{ base }}/account">
|
||||
<label for="athlete_id">Athlete ID</label>
|
||||
<input type="text" id="athlete_id" name="athlete_id" value="{{ record.athlete_id or '' if record else '' }}" placeholder="i123456" required>
|
||||
<label for="api_key">API key</label>
|
||||
@@ -30,5 +30,5 @@
|
||||
|
||||
<h2>Connect Claude</h2>
|
||||
<p>Add a custom connector in Claude pointing at:</p>
|
||||
<p><code>https://intervalsicu-mcp.farhoodlabs.com/mcp</code></p>
|
||||
<p><code>{{ mcp_url }}</code></p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
<td class="muted">{{ u.last_login_at.strftime('%Y-%m-%d') if u.last_login_at else '—' }}</td>
|
||||
<td>
|
||||
{% if u.enabled %}
|
||||
<form class="inline" method="post" action="/admin/{{ u.sub }}/disable"><button class="secondary" type="submit">Disable</button></form>
|
||||
<form class="inline" method="post" action="{{ base }}/admin/{{ u.sub }}/disable"><button class="secondary" type="submit">Disable</button></form>
|
||||
{% else %}
|
||||
<form class="inline" method="post" action="/admin/{{ u.sub }}/enable"><button type="submit">Approve</button></form>
|
||||
<form class="inline" method="post" action="{{ base }}/admin/{{ u.sub }}/enable"><button type="submit">Approve</button></form>
|
||||
{% endif %}
|
||||
<form class="inline" method="post" action="/admin/{{ u.sub }}/delete" onsubmit="return confirm('Delete {{ u.email }} and their stored credentials?');"><button class="secondary" type="submit">Delete</button></form>
|
||||
<form class="inline" method="post" action="{{ base }}/admin/{{ u.sub }}/delete" onsubmit="return confirm('Delete {{ u.email }} and their stored credentials?');"><button class="secondary" type="submit">Delete</button></form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -34,9 +34,9 @@
|
||||
<strong>Intervals.icu MCP</strong>
|
||||
{% if user %}
|
||||
<nav>
|
||||
<a href="/account">My account</a>
|
||||
{% if user.is_admin %}<a href="/admin">Admin</a>{% endif %}
|
||||
<a href="/logout">Sign out</a>
|
||||
<a href="{{ base }}/account">My account</a>
|
||||
{% if user.is_admin %}<a href="{{ base }}/admin">Admin</a>{% endif %}
|
||||
<a href="{{ base }}/logout">Sign out</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
{% block content %}
|
||||
<h1>Intervals.icu MCP portal</h1>
|
||||
<p>Sign in to connect your Intervals.icu account to the MCP server and manage your API credentials.</p>
|
||||
<p><a href="/auth/login"><button>Sign in</button></a></p>
|
||||
<p><a href="{{ base }}/auth/login"><button>Sign in</button></a></p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -41,6 +41,8 @@ def app_url(tmp_path, monkeypatch):
|
||||
admin_group="intervalsicu-mcp-admins",
|
||||
intervals_api_base="https://intervals.icu/api/v1",
|
||||
oidc_scopes="openid email profile groups",
|
||||
root_path="",
|
||||
mcp_url="https://intervalsicu.farhoodlabs.com/mcp",
|
||||
)
|
||||
return appmod.create_app(cfg), url
|
||||
|
||||
|
||||
Reference in New Issue
Block a user