refactor: move webapp from /user/page to /app

Simplified the webapp routing structure by consolidating the admin UI
to a single clean endpoint.

Changes:
- Moved webapp from /user/page to /app (root of mount)
- Removed /user JSON endpoint (no longer needed)
- Updated mount point from /user to /app in app.py
- Updated all route path checks (3 locations)
- Updated OAuth redirects to point to /app
- Updated all HTMX endpoint references
- Updated documentation (ADR-007, CHANGELOG)
- Added redirect from /app to /app/ for trailing slash handling

New Route Structure:
- /app - Main webapp (HTML UI with tabs)
- /app/revoke - Revoke background access
- /app/webhooks - Webhook management UI
- /app/webhooks/enable/{preset_id} - Enable webhook preset
- /app/webhooks/disable/{preset_id} - Disable webhook preset

Breaking Change: Existing bookmarks to /user or /user/page will no longer work.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-11-11 20:53:43 +01:00
parent f4759e424d
commit d86a185e04
6 changed files with 30 additions and 27 deletions
+1 -1
View File
@@ -86,7 +86,7 @@
- implement ADR-009 - refactor semantic search to use generic semantic:read scope
- implement MCP sampling for semantic search RAG (ADR-008)
- add optional vector database and semantic search to helm chart
- add vector sync processing status to /user/page endpoint
- add vector sync processing status to /app endpoint
- implement semantic search tool and fix vector sync issues (ADR-007 Phase 3)
- implement vector sync scanner and processor (ADR-007 Phase 2)
@@ -377,7 +377,7 @@ async def get_vector_sync_status(ctx: Context) -> dict:
}
```
The web UI (`/user/page` route) mirrors these controls with a simple toggle switch for enabling/disabling sync and a status display showing indexed counts and sync state. There is no job history, no detailed progress bars, no per-document status—just the essential information users need.
The web UI (`/app` route) mirrors these controls with a simple toggle switch for enabling/disabling sync and a status display showing indexed counts and sync state. There is no job history, no detailed progress bars, no per-document status—just the essential information users need.
### Authentication and Offline Access
+17 -14
View File
@@ -21,7 +21,7 @@ from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.middleware.cors import CORSMiddleware
from starlette.responses import JSONResponse
from starlette.responses import JSONResponse, RedirectResponse
from starlette.routing import Mount, Route
from nextcloud_mcp_server.auth import (
@@ -1038,7 +1038,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
# browser_app is in the same function scope (defined later in create_app)
# We need to find it in the mounted routes
for route in app.routes:
if isinstance(route, Mount) and route.path == "/user":
if isinstance(route, Mount) and route.path == "/app":
route.app.state.oauth_context = oauth_context_dict
logger.info(
"OAuth context shared with browser_app for session auth"
@@ -1059,7 +1059,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
# Also share with browser_app for webhook routes
for route in app.routes:
if isinstance(route, Mount) and route.path == "/user":
if isinstance(route, Mount) and route.path == "/app":
route.app.state.storage = storage
logger.info(
"Storage shared with browser_app for webhook management"
@@ -1099,15 +1099,15 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
app.state.shutdown_event = shutdown_event
app.state.scanner_wake_event = scanner_wake_event
# Also share with browser_app for /user/page route
# Also share with browser_app for /app route
for route in app.routes:
if isinstance(route, Mount) and route.path == "/user":
if isinstance(route, Mount) and route.path == "/app":
route.app.state.document_send_stream = send_stream
route.app.state.document_receive_stream = receive_stream
route.app.state.shutdown_event = shutdown_event
route.app.state.scanner_wake_event = scanner_wake_event
logger.info(
"Vector sync state shared with browser_app for /user/page"
"Vector sync state shared with browser_app for /app"
)
break
@@ -1410,7 +1410,6 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
from nextcloud_mcp_server.auth.userinfo_routes import (
revoke_session,
user_info_html,
user_info_json,
)
from nextcloud_mcp_server.auth.webhook_routes import (
disable_webhook_preset,
@@ -1421,13 +1420,12 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
# Create a separate Starlette app for browser routes that need session auth
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
browser_routes = [
Route("/", user_info_json, methods=["GET"]), # /user/ → user_info_json
Route("/page", user_info_html, methods=["GET"]), # /user/page → user_info_html
Route("/", user_info_html, methods=["GET"]), # /app → webapp (HTML UI)
Route(
"/revoke", revoke_session, methods=["POST"], name="revoke_session_endpoint"
), # /user/revoke → revoke_session
), # /app/revoke → revoke_session
# Webhook management routes (admin-only)
Route("/webhooks", webhook_management_pane, methods=["GET"]), # /user/webhooks
Route("/webhooks", webhook_management_pane, methods=["GET"]), # /app/webhooks
Route(
"/webhooks/enable/{preset_id:str}", enable_webhook_preset, methods=["POST"]
),
@@ -1444,9 +1442,14 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
backend=SessionAuthBackend(oauth_enabled=oauth_enabled),
)
# Mount browser app at /user (so /user and /user/page work)
routes.append(Mount("/user", app=browser_app))
logger.info("User info routes with session auth: /user, /user/page")
# Add redirect from /app to /app/ (Starlette requires trailing slash for mounted apps)
routes.append(
Route("/app", lambda request: RedirectResponse("/app/", status_code=307))
)
# Mount browser app at /app (webapp and admin routes)
routes.append(Mount("/app", app=browser_app))
logger.info("App routes with session auth: /app, /app/webhooks, /app/revoke")
# Mount FastMCP at root last (catch-all, handles OAuth via token_verifier)
routes.append(Mount("/", app=mcp_app))
@@ -1,7 +1,7 @@
"""Browser-based OAuth login routes for admin UI.
Separate from MCP OAuth flow - these routes establish browser sessions
for accessing admin UI endpoints like /user/page.
for accessing admin UI endpoints like /app.
"""
import hashlib
@@ -38,8 +38,8 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
"""
oauth_ctx = request.app.state.oauth_context
if not oauth_ctx:
# BasicAuth mode - no login needed, redirect to user page
return RedirectResponse("/user/page", status_code=302)
# BasicAuth mode - no login needed, redirect to app
return RedirectResponse("/app", status_code=302)
storage = oauth_ctx["storage"]
oauth_client = oauth_ctx["oauth_client"]
@@ -71,7 +71,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
await storage.store_oauth_session(
session_id=state, # Use state as session ID
client_id="browser-ui",
client_redirect_uri="/user/page",
client_redirect_uri="/app",
state=state,
code_challenge=code_challenge,
code_challenge_method="S256",
@@ -383,7 +383,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
# Continue anyway - profile cache is optional for browser UI
# Create response and set session cookie
response = RedirectResponse("/user/page", status_code=302)
response = RedirectResponse("/app", status_code=302)
response.set_cookie(
key="mcp_session",
value=user_id,
+1 -1
View File
@@ -602,7 +602,7 @@ async def user_info_html(request: Request) -> HTMLResponse:
webhooks_tab_html = ""
if show_webhooks_tab:
webhooks_tab_html = """
<div hx-get="/user/webhooks" hx-trigger="load" hx-swap="outerHTML">
<div hx-get="/app/webhooks" hx-trigger="load" hx-swap="outerHTML">
<p style="color: #999;">Loading webhook management...</p>
</div>
"""
+5 -5
View File
@@ -32,7 +32,7 @@ def _get_storage(request: Request):
Returns:
RefreshTokenStorage instance or None
"""
# Try browser_app state first (for /user routes)
# Try browser_app state first (for /app routes)
storage = getattr(request.app.state, "storage", None)
# Try oauth_context if in OAuth mode
@@ -289,7 +289,7 @@ async def webhook_management_pane(request: Request) -> HTMLResponse:
status_badge = f'<span style="color: #4caf50; font-weight: bold;">✓ Enabled ({num_webhooks} webhooks)</span>'
action_button = f"""
<button
hx-delete="/user/webhooks/disable/{preset_id}"
hx-delete="/app/webhooks/disable/{preset_id}"
hx-target="#preset-{preset_id}"
hx-swap="outerHTML"
class="button"
@@ -301,7 +301,7 @@ async def webhook_management_pane(request: Request) -> HTMLResponse:
status_badge = '<span style="color: #999;">Not Enabled</span>'
action_button = f"""
<button
hx-post="/user/webhooks/enable/{preset_id}"
hx-post="/app/webhooks/enable/{preset_id}"
hx-target="#preset-{preset_id}"
hx-swap="outerHTML"
class="button button-primary">
@@ -429,7 +429,7 @@ async def enable_webhook_preset(request: Request) -> HTMLResponse:
<div><span style="color: #4caf50; font-weight: bold;"> Enabled ({num_webhooks} webhooks)</span></div>
<div>
<button
hx-delete="/user/webhooks/disable/{preset_id}"
hx-delete="/app/webhooks/disable/{preset_id}"
hx-target="#preset-{preset_id}"
hx-swap="outerHTML"
class="button"
@@ -520,7 +520,7 @@ async def disable_webhook_preset(request: Request) -> HTMLResponse:
<div><span style="color: #999;">Not Enabled</span></div>
<div>
<button
hx-post="/user/webhooks/enable/{preset_id}"
hx-post="/app/webhooks/enable/{preset_id}"
hx-target="#preset-{preset_id}"
hx-swap="outerHTML"
class="button button-primary">