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:
+1
-1
@@ -86,7 +86,7 @@
|
|||||||
- implement ADR-009 - refactor semantic search to use generic semantic:read scope
|
- implement ADR-009 - refactor semantic search to use generic semantic:read scope
|
||||||
- implement MCP sampling for semantic search RAG (ADR-008)
|
- implement MCP sampling for semantic search RAG (ADR-008)
|
||||||
- add optional vector database and semantic search to helm chart
|
- 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 semantic search tool and fix vector sync issues (ADR-007 Phase 3)
|
||||||
- implement vector sync scanner and processor (ADR-007 Phase 2)
|
- 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
|
### Authentication and Offline Access
|
||||||
|
|
||||||
|
|||||||
+17
-14
@@ -21,7 +21,7 @@ from pydantic import AnyHttpUrl
|
|||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||||
from starlette.middleware.cors import CORSMiddleware
|
from starlette.middleware.cors import CORSMiddleware
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse, RedirectResponse
|
||||||
from starlette.routing import Mount, Route
|
from starlette.routing import Mount, Route
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import (
|
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)
|
# browser_app is in the same function scope (defined later in create_app)
|
||||||
# We need to find it in the mounted routes
|
# We need to find it in the mounted routes
|
||||||
for route in app.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
|
route.app.state.oauth_context = oauth_context_dict
|
||||||
logger.info(
|
logger.info(
|
||||||
"OAuth context shared with browser_app for session auth"
|
"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
|
# Also share with browser_app for webhook routes
|
||||||
for route in app.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
|
route.app.state.storage = storage
|
||||||
logger.info(
|
logger.info(
|
||||||
"Storage shared with browser_app for webhook management"
|
"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.shutdown_event = shutdown_event
|
||||||
app.state.scanner_wake_event = scanner_wake_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:
|
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_send_stream = send_stream
|
||||||
route.app.state.document_receive_stream = receive_stream
|
route.app.state.document_receive_stream = receive_stream
|
||||||
route.app.state.shutdown_event = shutdown_event
|
route.app.state.shutdown_event = shutdown_event
|
||||||
route.app.state.scanner_wake_event = scanner_wake_event
|
route.app.state.scanner_wake_event = scanner_wake_event
|
||||||
logger.info(
|
logger.info(
|
||||||
"Vector sync state shared with browser_app for /user/page"
|
"Vector sync state shared with browser_app for /app"
|
||||||
)
|
)
|
||||||
break
|
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 (
|
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||||
revoke_session,
|
revoke_session,
|
||||||
user_info_html,
|
user_info_html,
|
||||||
user_info_json,
|
|
||||||
)
|
)
|
||||||
from nextcloud_mcp_server.auth.webhook_routes import (
|
from nextcloud_mcp_server.auth.webhook_routes import (
|
||||||
disable_webhook_preset,
|
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
|
# Create a separate Starlette app for browser routes that need session auth
|
||||||
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
|
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
|
||||||
browser_routes = [
|
browser_routes = [
|
||||||
Route("/", user_info_json, methods=["GET"]), # /user/ → user_info_json
|
Route("/", user_info_html, methods=["GET"]), # /app → webapp (HTML UI)
|
||||||
Route("/page", user_info_html, methods=["GET"]), # /user/page → user_info_html
|
|
||||||
Route(
|
Route(
|
||||||
"/revoke", revoke_session, methods=["POST"], name="revoke_session_endpoint"
|
"/revoke", revoke_session, methods=["POST"], name="revoke_session_endpoint"
|
||||||
), # /user/revoke → revoke_session
|
), # /app/revoke → revoke_session
|
||||||
# Webhook management routes (admin-only)
|
# Webhook management routes (admin-only)
|
||||||
Route("/webhooks", webhook_management_pane, methods=["GET"]), # /user/webhooks
|
Route("/webhooks", webhook_management_pane, methods=["GET"]), # /app/webhooks
|
||||||
Route(
|
Route(
|
||||||
"/webhooks/enable/{preset_id:str}", enable_webhook_preset, methods=["POST"]
|
"/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),
|
backend=SessionAuthBackend(oauth_enabled=oauth_enabled),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mount browser app at /user (so /user and /user/page work)
|
# Add redirect from /app to /app/ (Starlette requires trailing slash for mounted apps)
|
||||||
routes.append(Mount("/user", app=browser_app))
|
routes.append(
|
||||||
logger.info("User info routes with session auth: /user, /user/page")
|
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)
|
# Mount FastMCP at root last (catch-all, handles OAuth via token_verifier)
|
||||||
routes.append(Mount("/", app=mcp_app))
|
routes.append(Mount("/", app=mcp_app))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Browser-based OAuth login routes for admin UI.
|
"""Browser-based OAuth login routes for admin UI.
|
||||||
|
|
||||||
Separate from MCP OAuth flow - these routes establish browser sessions
|
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
|
import hashlib
|
||||||
@@ -38,8 +38,8 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
"""
|
"""
|
||||||
oauth_ctx = request.app.state.oauth_context
|
oauth_ctx = request.app.state.oauth_context
|
||||||
if not oauth_ctx:
|
if not oauth_ctx:
|
||||||
# BasicAuth mode - no login needed, redirect to user page
|
# BasicAuth mode - no login needed, redirect to app
|
||||||
return RedirectResponse("/user/page", status_code=302)
|
return RedirectResponse("/app", status_code=302)
|
||||||
|
|
||||||
storage = oauth_ctx["storage"]
|
storage = oauth_ctx["storage"]
|
||||||
oauth_client = oauth_ctx["oauth_client"]
|
oauth_client = oauth_ctx["oauth_client"]
|
||||||
@@ -71,7 +71,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
await storage.store_oauth_session(
|
await storage.store_oauth_session(
|
||||||
session_id=state, # Use state as session ID
|
session_id=state, # Use state as session ID
|
||||||
client_id="browser-ui",
|
client_id="browser-ui",
|
||||||
client_redirect_uri="/user/page",
|
client_redirect_uri="/app",
|
||||||
state=state,
|
state=state,
|
||||||
code_challenge=code_challenge,
|
code_challenge=code_challenge,
|
||||||
code_challenge_method="S256",
|
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
|
# Continue anyway - profile cache is optional for browser UI
|
||||||
|
|
||||||
# Create response and set session cookie
|
# Create response and set session cookie
|
||||||
response = RedirectResponse("/user/page", status_code=302)
|
response = RedirectResponse("/app", status_code=302)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key="mcp_session",
|
key="mcp_session",
|
||||||
value=user_id,
|
value=user_id,
|
||||||
|
|||||||
@@ -602,7 +602,7 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
|||||||
webhooks_tab_html = ""
|
webhooks_tab_html = ""
|
||||||
if show_webhooks_tab:
|
if show_webhooks_tab:
|
||||||
webhooks_tab_html = """
|
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>
|
<p style="color: #999;">Loading webhook management...</p>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def _get_storage(request: Request):
|
|||||||
Returns:
|
Returns:
|
||||||
RefreshTokenStorage instance or None
|
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)
|
storage = getattr(request.app.state, "storage", None)
|
||||||
|
|
||||||
# Try oauth_context if in OAuth mode
|
# 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>'
|
status_badge = f'<span style="color: #4caf50; font-weight: bold;">✓ Enabled ({num_webhooks} webhooks)</span>'
|
||||||
action_button = f"""
|
action_button = f"""
|
||||||
<button
|
<button
|
||||||
hx-delete="/user/webhooks/disable/{preset_id}"
|
hx-delete="/app/webhooks/disable/{preset_id}"
|
||||||
hx-target="#preset-{preset_id}"
|
hx-target="#preset-{preset_id}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
class="button"
|
class="button"
|
||||||
@@ -301,7 +301,7 @@ async def webhook_management_pane(request: Request) -> HTMLResponse:
|
|||||||
status_badge = '<span style="color: #999;">Not Enabled</span>'
|
status_badge = '<span style="color: #999;">Not Enabled</span>'
|
||||||
action_button = f"""
|
action_button = f"""
|
||||||
<button
|
<button
|
||||||
hx-post="/user/webhooks/enable/{preset_id}"
|
hx-post="/app/webhooks/enable/{preset_id}"
|
||||||
hx-target="#preset-{preset_id}"
|
hx-target="#preset-{preset_id}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
class="button button-primary">
|
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><span style="color: #4caf50; font-weight: bold;">✓ Enabled ({num_webhooks} webhooks)</span></div>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
hx-delete="/user/webhooks/disable/{preset_id}"
|
hx-delete="/app/webhooks/disable/{preset_id}"
|
||||||
hx-target="#preset-{preset_id}"
|
hx-target="#preset-{preset_id}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
class="button"
|
class="button"
|
||||||
@@ -520,7 +520,7 @@ async def disable_webhook_preset(request: Request) -> HTMLResponse:
|
|||||||
<div><span style="color: #999;">Not Enabled</span></div>
|
<div><span style="color: #999;">Not Enabled</span></div>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
hx-post="/user/webhooks/enable/{preset_id}"
|
hx-post="/app/webhooks/enable/{preset_id}"
|
||||||
hx-target="#preset-{preset_id}"
|
hx-target="#preset-{preset_id}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
class="button button-primary">
|
class="button button-primary">
|
||||||
|
|||||||
Reference in New Issue
Block a user