feat(dev_tools): add diagnostics hub with permissions audit tool

Evolve the platform-debug page into a diagnostics hub with sidebar
explorer layout. Add permissions audit API that introspects all
registered page routes and reports auth/permission enforcement status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 15:44:49 +01:00
parent efca9734d2
commit 618376aa39
9 changed files with 687 additions and 120 deletions

View File

@@ -82,7 +82,7 @@ dev_tools_module = ModuleDefinition(
), ),
MenuItemDefinition( MenuItemDefinition(
id="platform-debug", id="platform-debug",
label_key="dev_tools.menu.platform_debug", label_key="dev_tools.menu.diagnostics",
icon="search", icon="search",
route="/admin/platform-debug", route="/admin/platform-debug",
order=40, order=40,

View File

@@ -14,9 +14,20 @@
"components": "Komponenten", "components": "Komponenten",
"icons": "Icons", "icons": "Icons",
"sql_query": "SQL Abfrage", "sql_query": "SQL Abfrage",
"platform_debug": "Plattform Debug", "diagnostics": "Diagnostik",
"translation_editor": "Übersetzungseditor" "translation_editor": "Übersetzungseditor"
}, },
"diagnostics": {
"title": "Diagnostik",
"platform_trace": "Plattform-Trace",
"permissions_audit": "Berechtigungsprüfung",
"run_audit": "Prüfung starten",
"status_ok": "OK",
"status_warning": "Warnung",
"status_error": "Fehler",
"total_routes": "Routen gesamt",
"no_routes_match": "Keine Routen entsprechen Ihren Filtern"
},
"translation_editor": { "translation_editor": {
"title": "Übersetzungseditor", "title": "Übersetzungseditor",
"all_modules": "Alle Module", "all_modules": "Alle Module",

View File

@@ -14,9 +14,20 @@
"components": "Components", "components": "Components",
"icons": "Icons", "icons": "Icons",
"sql_query": "SQL Query", "sql_query": "SQL Query",
"platform_debug": "Platform Debug", "diagnostics": "Diagnostics",
"translation_editor": "Translation Editor" "translation_editor": "Translation Editor"
}, },
"diagnostics": {
"title": "Diagnostics",
"platform_trace": "Platform Trace",
"permissions_audit": "Permissions Audit",
"run_audit": "Run Audit",
"status_ok": "OK",
"status_warning": "Warning",
"status_error": "Error",
"total_routes": "Total Routes",
"no_routes_match": "No routes match your filters"
},
"translation_editor": { "translation_editor": {
"title": "Translation Editor", "title": "Translation Editor",
"all_modules": "All Modules", "all_modules": "All Modules",

View File

@@ -14,9 +14,20 @@
"components": "Composants", "components": "Composants",
"icons": "Icônes", "icons": "Icônes",
"sql_query": "Requête SQL", "sql_query": "Requête SQL",
"platform_debug": "Debug Plateforme", "diagnostics": "Diagnostics",
"translation_editor": "Éditeur de traductions" "translation_editor": "Éditeur de traductions"
}, },
"diagnostics": {
"title": "Diagnostics",
"platform_trace": "Trace Plateforme",
"permissions_audit": "Audit des Permissions",
"run_audit": "Lancer l'audit",
"status_ok": "OK",
"status_warning": "Avertissement",
"status_error": "Erreur",
"total_routes": "Routes totales",
"no_routes_match": "Aucune route ne correspond à vos filtres"
},
"translation_editor": { "translation_editor": {
"title": "Éditeur de traductions", "title": "Éditeur de traductions",
"all_modules": "Tous les modules", "all_modules": "Tous les modules",

View File

@@ -14,9 +14,20 @@
"components": "Komponenten", "components": "Komponenten",
"icons": "Icons", "icons": "Icons",
"sql_query": "SQL Ufro", "sql_query": "SQL Ufro",
"platform_debug": "Plattform Debug", "diagnostics": "Diagnostik",
"translation_editor": "Iwwersetzungseditor" "translation_editor": "Iwwersetzungseditor"
}, },
"diagnostics": {
"title": "Diagnostik",
"platform_trace": "Plattform-Trace",
"permissions_audit": "Berechtigungsprüfung",
"run_audit": "Prüfung starten",
"status_ok": "OK",
"status_warning": "Warnung",
"status_error": "Feeler",
"total_routes": "Routen gesamt",
"no_routes_match": "Keng Routen entspriechen Ären Filteren"
},
"translation_editor": { "translation_editor": {
"title": "Iwwersetzungseditor", "title": "Iwwersetzungseditor",
"all_modules": "All Moduler", "all_modules": "All Moduler",

View File

@@ -7,6 +7,9 @@ Aggregates all admin API routers for the dev-tools module.
from fastapi import APIRouter from fastapi import APIRouter
from app.modules.dev_tools.routes.api.admin_diagnostics import (
router as diagnostics_router,
)
from app.modules.dev_tools.routes.api.admin_platform_debug import ( from app.modules.dev_tools.routes.api.admin_platform_debug import (
router as platform_debug_router, router as platform_debug_router,
) )
@@ -19,3 +22,4 @@ router = APIRouter()
router.include_router(sql_query_router, tags=["sql-query"]) router.include_router(sql_query_router, tags=["sql-query"])
router.include_router(platform_debug_router, tags=["platform-debug"]) router.include_router(platform_debug_router, tags=["platform-debug"])
router.include_router(translations_router, tags=["translations"]) router.include_router(translations_router, tags=["translations"])
router.include_router(diagnostics_router, tags=["diagnostics"])

View File

@@ -0,0 +1,289 @@
# app/modules/dev_tools/routes/api/admin_diagnostics.py
"""
Diagnostics API endpoints.
Provides route introspection and permissions audit capabilities
for the Diagnostics Hub admin page.
"""
import inspect
import logging
from fastapi import APIRouter, Depends, Request
from fastapi.routing import APIRoute
from pydantic import BaseModel
from app.api.deps import get_current_super_admin_api
from app.modules.tenancy.schemas.auth import UserContext
router = APIRouter(prefix="/diagnostics")
logger = logging.getLogger(__name__)
# ── Response schemas ──
class RouteAuditEntry(BaseModel):
path: str
methods: list[str]
module: str | None
frontend: str | None
endpoint_name: str
auth_dependency: str | None
auth_detail: str | None
status: str # "ok", "warning", "error"
issue: str | None
class FrontendSummary(BaseModel):
total: int = 0
ok: int = 0
warnings: int = 0
errors: int = 0
class AuditSummary(BaseModel):
total: int = 0
ok: int = 0
warnings: int = 0
errors: int = 0
by_frontend: dict[str, FrontendSummary] = {}
class PermissionsAuditResponse(BaseModel):
routes: list[RouteAuditEntry]
summary: AuditSummary
# ── Auth dependency names for classification ──
_OK_DEPENDENCIES = {
"require_menu_access",
"require_store_page_permission",
"get_current_super_admin",
"get_current_super_admin_api",
}
_ADMIN_AUTH_DEPENDENCIES = {
"get_current_admin_from_cookie_or_header",
}
_STORE_AUTH_DEPENDENCIES = {
"get_current_store_from_cookie_or_header",
}
_MERCHANT_AUTH_DEPENDENCIES = {
"get_current_merchant_from_cookie_or_header",
}
def _classify_frontend(path: str) -> str | None:
"""Classify frontend type from route path prefix."""
if path.startswith("/admin/"):
return "admin"
if path.startswith(("/store/", "/store/{")):
return "store"
if path.startswith(("/merchants/", "/merchants/{")):
return "merchant"
if path.startswith(("/storefront/", "/storefront/{")):
return "storefront"
return None
def _infer_module(path: str) -> str | None:
"""Infer module name from path segments."""
# Strip frontend prefix to get module segment
# e.g. /admin/loyalty/programs → loyalty
# e.g. /store/{store_code}/loyalty/terminal → loyalty
parts = path.strip("/").split("/")
if len(parts) < 2:
return None
# Skip the frontend prefix and optional path params
start = 1
# For store/merchant with {store_code}/{merchant_code} param
if len(parts) > 2 and parts[1].startswith("{"):
start = 2
if start < len(parts):
candidate = parts[start]
# Skip common non-module segments
if candidate not in {"dashboard", "settings", "profile", "login", "logout", "api"}:
return candidate
return None
def _extract_auth_dependencies(endpoint) -> list[dict]:
"""
Inspect endpoint function signature to find Depends() parameters
and extract auth-related dependency information.
"""
results = []
try:
sig = inspect.signature(endpoint)
except (ValueError, TypeError):
return results
for _param_name, param in sig.parameters.items():
default = param.default
if default is inspect.Parameter.empty:
continue
# Check for Depends() instances
dep_callable = None
if hasattr(default, "dependency"):
dep_callable = default.dependency
elif callable(default) and not isinstance(default, type):
continue # Skip plain callables that aren't Depends
if dep_callable is None:
continue
dep_name = None
dep_detail = None
# Direct function reference
if callable(dep_callable) and hasattr(dep_callable, "__name__"):
dep_name = dep_callable.__name__
# functools.partial (e.g. require_menu_access("id", FrontendType.ADMIN))
if hasattr(dep_callable, "func"):
dep_name = getattr(dep_callable.func, "__name__", str(dep_callable.func))
# Extract args for detail
if dep_callable.args:
dep_detail = str(dep_callable.args[0])
# Closure / nested function from factory
if dep_name is None and callable(dep_callable):
# Try qualname for closures from factories
qualname = getattr(dep_callable, "__qualname__", "")
if "." in qualname:
# e.g. "require_menu_access.<locals>.dependency"
factory_name = qualname.split(".")[0]
dep_name = factory_name
# Try to extract closure variables for detail
if hasattr(dep_callable, "__closure__") and dep_callable.__closure__:
for cell in dep_callable.__closure__:
try:
val = cell.cell_contents
if isinstance(val, str) and val not in ("admin", "store", "merchant"):
dep_detail = val
break
except ValueError:
continue
if dep_name:
results.append({"name": dep_name, "detail": dep_detail})
return results
def _classify_route(path: str, frontend: str | None, auth_deps: list[dict]) -> tuple[str, str | None, str | None, str | None]:
"""
Classify a route's auth status.
Returns: (status, auth_dependency, auth_detail, issue)
"""
if not auth_deps:
return "error", None, None, "No authentication dependency found"
# Check each dependency against classification rules
for dep in auth_deps:
name = dep["name"]
detail = dep["detail"]
if name in _OK_DEPENDENCIES:
return "ok", name, detail, None
if name in _ADMIN_AUTH_DEPENDENCIES and frontend == "admin":
return "ok", name, detail, None
# If we get here, check for auth-only deps (warnings)
for dep in auth_deps:
name = dep["name"]
detail = dep["detail"]
if name in _STORE_AUTH_DEPENDENCIES and frontend == "store":
return "warning", name, detail, "Authentication only \u2014 no menu access or permission check"
if name in _MERCHANT_AUTH_DEPENDENCIES and frontend == "merchant":
return "warning", name, detail, "Authentication only \u2014 no menu access check"
if name in _ADMIN_AUTH_DEPENDENCIES:
return "warning", name, detail, "Admin auth on non-admin route"
# Has some dependency but not recognized
first = auth_deps[0]
return "warning", first["name"], first["detail"], f"Unrecognized auth dependency: {first['name']}"
@router.get("/permissions-audit", response_model=PermissionsAuditResponse)
def permissions_audit(
request: Request,
current_user: UserContext = Depends(get_current_super_admin_api),
):
"""
Introspect all registered page routes and audit their auth/permission enforcement.
Examines each route's endpoint function signature to find Depends() parameters
and classify the level of authentication and authorization applied.
"""
audit_routes: list[RouteAuditEntry] = []
for route in request.app.routes:
if not isinstance(route, APIRoute):
continue
# Filter to page routes only (include_in_schema=False signals HTML page routes)
if route.include_in_schema:
continue
path = route.path
methods = sorted(route.methods - {"HEAD", "OPTIONS"}) if route.methods else []
frontend = _classify_frontend(path)
module = _infer_module(path)
endpoint_name = getattr(route.endpoint, "__name__", str(route.endpoint))
# Extract and classify auth dependencies
auth_deps = _extract_auth_dependencies(route.endpoint)
status, auth_dep, auth_detail, issue = _classify_route(path, frontend, auth_deps)
audit_routes.append(RouteAuditEntry(
path=path,
methods=methods,
module=module,
frontend=frontend,
endpoint_name=endpoint_name,
auth_dependency=auth_dep,
auth_detail=auth_detail,
status=status,
issue=issue,
))
# Sort by frontend, then path
audit_routes.sort(key=lambda r: (r.frontend or "", r.path))
# Build summary
summary = AuditSummary(
total=len(audit_routes),
ok=sum(1 for r in audit_routes if r.status == "ok"),
warnings=sum(1 for r in audit_routes if r.status == "warning"),
errors=sum(1 for r in audit_routes if r.status == "error"),
by_frontend={},
)
for r in audit_routes:
fe = r.frontend or "other"
if fe not in summary.by_frontend:
summary.by_frontend[fe] = FrontendSummary()
fe_summary = summary.by_frontend[fe]
fe_summary.total += 1
if r.status == "ok":
fe_summary.ok += 1
elif r.status == "warning":
fe_summary.warnings += 1
elif r.status == "error":
fe_summary.errors += 1
return PermissionsAuditResponse(routes=audit_routes, summary=summary)

View File

@@ -140,8 +140,8 @@ async def admin_platform_debug_page(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
Render platform resolution debug page. Render diagnostics hub page.
Traces middleware pipeline for all URL patterns. Provides platform trace, permissions audit, and other diagnostic tools.
""" """
return templates.TemplateResponse( return templates.TemplateResponse(
"dev_tools/admin/platform-debug.html", "dev_tools/admin/platform-debug.html",

View File

@@ -1,20 +1,67 @@
{# app/modules/dev_tools/templates/dev_tools/admin/platform-debug.html #} {# app/modules/dev_tools/templates/dev_tools/admin/platform-debug.html #}
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header, table_empty_state %}
{% block title %}Platform Resolution Debug{% endblock %} {% block title %}Diagnostics{% endblock %}
{% block alpine_data %}platformDebug(){% endblock %} {% block alpine_data %}platformDebug(){% endblock %}
{% block content %} {% block content %}
<div class="mb-6"> {{ page_header('Diagnostics', back_url='/admin/dashboard', back_label='Back to Dashboard') }}
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Platform Resolution Trace</h1>
<div class="flex gap-6">
<!-- ═══════════════════════════════════════════════════════════════════ -->
<!-- Left sidebar — Diagnostic Tools explorer -->
<!-- ═══════════════════════════════════════════════════════════════════ -->
<div class="w-72 flex-shrink-0">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3 flex items-center gap-1.5">
<span x-html="$icon('cog', 'w-4 h-4')"></span>
Diagnostic Tools
</div>
<template x-for="group in toolGroups" :key="group.category">
<div class="mb-1">
<button @click="toggleCategory(group.category)"
class="flex items-center justify-between w-full text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase px-2 py-1 rounded hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
<span x-text="group.category"></span>
<span class="text-[10px] font-mono leading-none" x-text="isCategoryExpanded(group.category) ? '' : '+'"></span>
</button>
<ul x-show="isCategoryExpanded(group.category)" x-collapse class="space-y-0.5 mt-0.5">
<template x-for="tool in group.items" :key="tool.id">
<li @click="selectTool(tool.id)"
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm cursor-pointer transition-colors"
:class="activeTool === tool.id
? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-200'">
<span x-html="$icon(tool.icon, 'w-3.5 h-3.5 flex-shrink-0')"></span>
<span class="truncate" x-text="tool.label"></span>
</li>
</template>
</ul>
</div>
</template>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════ -->
<!-- Main area — tool content panels -->
<!-- ═══════════════════════════════════════════════════════════════════ -->
<div class="flex-1 min-w-0">
<!-- ─────────────────────────────────────────────────────────── -->
<!-- Tool: Platform Trace (existing functionality, verbatim) -->
<!-- ─────────────────────────────────────────────────────────── -->
<div x-show="activeTool === 'platform-trace'" x-cloak>
<div class="mb-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Platform Resolution Trace</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Simulates the middleware pipeline for each URL pattern to trace how platform &amp; store context are resolved. Simulates the middleware pipeline for each URL pattern to trace how platform &amp; store context are resolved.
</p> </p>
</div> </div>
<!-- Controls --> <!-- Controls -->
<div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs"> <div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs">
<div class="flex items-center gap-4 flex-wrap"> <div class="flex items-center gap-4 flex-wrap">
<button @click="runAllTests()" :disabled="running" <button @click="runAllTests()" :disabled="running"
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"> class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
@@ -34,10 +81,10 @@
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Custom Domain</button> class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Custom Domain</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Custom test --> <!-- Custom test -->
<div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs"> <div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Custom Test</h3> <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Custom Test</h3>
<div class="flex gap-3 items-end flex-wrap"> <div class="flex gap-3 items-end flex-wrap">
<div> <div>
@@ -65,10 +112,10 @@
<div x-html="renderTrace(customResult)"></div> <div x-html="renderTrace(customResult)"></div>
</div> </div>
</template> </template>
</div> </div>
<!-- Test Results --> <!-- Test Results -->
<div class="space-y-4"> <div class="space-y-4">
<template x-for="test in filteredTests" :key="test.id"> <template x-for="test in filteredTests" :key="test.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs overflow-hidden"> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs overflow-hidden">
<!-- Header --> <!-- Header -->
@@ -127,11 +174,168 @@
</div> </div>
</div> </div>
</template> </template>
</div>
</div>
<!-- ─────────────────────────────────────────────────────────── -->
<!-- Tool: Permissions Audit -->
<!-- ─────────────────────────────────────────────────────────── -->
<div x-show="activeTool === 'permissions-audit'" x-cloak>
<div class="mb-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Permissions Audit</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Introspects all registered page routes and reports which have proper auth/permission enforcement.
</p>
</div>
<!-- Run Audit button -->
<div class="mb-6">
<button @click="runPermissionsAudit()" :disabled="auditLoading"
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 text-sm font-medium">
<span x-show="!auditLoading">Run Audit</span>
<span x-show="auditLoading">Running...</span>
</button>
<span x-show="auditError" class="ml-3 text-sm text-red-600 dark:text-red-400" x-text="auditError"></span>
</div>
<!-- Summary cards -->
<template x-if="auditSummary">
<div class="grid grid-cols-4 gap-4 mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-4 text-center">
<div class="text-2xl font-bold text-gray-900 dark:text-white" x-text="auditSummary.total"></div>
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mt-1">Total Routes</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-4 text-center border-t-2 border-green-500">
<div class="text-2xl font-bold text-green-600 dark:text-green-400" x-text="auditSummary.ok"></div>
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mt-1">OK</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-4 text-center border-t-2 border-amber-500">
<div class="text-2xl font-bold text-amber-600 dark:text-amber-400" x-text="auditSummary.warnings"></div>
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mt-1">Warnings</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-4 text-center border-t-2 border-red-500">
<div class="text-2xl font-bold text-red-600 dark:text-red-400" x-text="auditSummary.errors"></div>
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mt-1">Errors</div>
</div>
</div>
</template>
<!-- Filter bar -->
<template x-if="auditRoutes.length > 0">
<div class="mb-4 flex items-center gap-4">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Frontend</label>
<select x-model="auditFilterFrontend"
class="px-3 py-1.5 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white">
<option value="all">All</option>
<option value="admin">Admin</option>
<option value="store">Store</option>
<option value="merchant">Merchant</option>
<option value="storefront">Storefront</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Status</label>
<select x-model="auditFilterStatus"
class="px-3 py-1.5 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white">
<option value="all">All</option>
<option value="ok">OK</option>
<option value="warning">Warnings</option>
<option value="error">Errors</option>
</select>
</div>
<div class="ml-auto text-sm text-gray-500 dark:text-gray-400">
Showing <span class="font-medium text-gray-900 dark:text-white" x-text="filteredAuditRoutes.length"></span>
of <span x-text="auditRoutes.length"></span> routes
</div>
</div>
</template>
<!-- Results table -->
<template x-if="auditRoutes.length > 0">
{% call table_wrapper() %}
{{ table_header(['Status', 'Frontend', 'Path', 'Endpoint', 'Auth', 'Issue']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
{{ table_empty_state(6, title='No routes match your filters', icon='shield-check', show_condition='filteredAuditRoutes.length === 0') }}
<template x-for="route in filteredAuditRoutes" :key="route.path + route.methods.join()">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm">
<td class="px-4 py-2.5">
<span class="text-xs px-2 py-0.5 rounded-full font-medium"
:class="{
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300': route.status === 'ok',
'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300': route.status === 'warning',
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300': route.status === 'error'
}" x-text="route.status"></span>
</td>
<td class="px-4 py-2.5">
<span class="text-xs px-2 py-0.5 rounded-full font-medium"
:class="{
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': route.frontend === 'admin',
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': route.frontend === 'store',
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300': route.frontend === 'merchant',
'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300': route.frontend === 'storefront',
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300': !route.frontend
}" x-text="route.frontend || 'other'"></span>
</td>
<td class="px-4 py-2.5 font-mono text-xs" x-text="route.path"></td>
<td class="px-4 py-2.5 text-xs text-gray-500 dark:text-gray-400" x-text="route.endpoint_name"></td>
<td class="px-4 py-2.5 text-xs">
<span x-text="route.auth_dependency || '—'" class="font-mono"></span>
<template x-if="route.auth_detail">
<span class="text-gray-400 dark:text-gray-500 ml-1" x-text="'(' + route.auth_detail + ')'"></span>
</template>
</td>
<td class="px-4 py-2.5 text-xs text-gray-500 dark:text-gray-400" x-text="route.issue || '—'"></td>
</tr>
</template>
</tbody>
{% endcall %}
</template>
</div>
</div>
</div> </div>
<script> <script>
function platformDebug() { function platformDebug() {
return { return {
// Inherit base layout functionality
...data(),
// Page identifier
currentPage: 'platform-debug',
// ── Sidebar / tool navigation ──
activeTool: 'platform-trace',
expandedCategories: ['Resolution', 'Security'],
toolGroups: [
{
category: 'Resolution',
items: [
{ id: 'platform-trace', label: 'Platform Trace', icon: 'search' },
],
},
{
category: 'Security',
items: [
{ id: 'permissions-audit', label: 'Permissions Audit', icon: 'shield-check' },
],
},
],
toggleCategory(cat) {
const idx = this.expandedCategories.indexOf(cat);
if (idx >= 0) this.expandedCategories.splice(idx, 1);
else this.expandedCategories.push(cat);
},
isCategoryExpanded(cat) {
return this.expandedCategories.includes(cat);
},
selectTool(toolId) {
this.activeTool = toolId;
},
// ── Platform Trace (existing) ──
running: false, running: false,
filterGroup: 'all', filterGroup: 'all',
customHost: 'localhost:8000', customHost: 'localhost:8000',
@@ -308,9 +512,6 @@ function platformDebug() {
}, },
// ── Prod custom domain ── // ── Prod custom domain ──
// Uses real StoreDomain records:
// wizatech.shop → store_id=1 (WIZATECH), platform_id=1 (oms)
// fashionhub.store → store_id=4 (FASHIONHUB), platform_id=3 (loyalty)
{ {
id: 'prod-c-1', group: 'prod-custom', groupLabel: 'Prod Custom', id: 'prod-c-1', group: 'prod-custom', groupLabel: 'Prod Custom',
label: 'wizatech.shop /store/login', label: 'wizatech.shop /store/login',
@@ -460,6 +661,35 @@ function platformDebug() {
return html; return html;
}, },
// ── Permissions Audit ──
auditRoutes: [],
auditSummary: null,
auditLoading: false,
auditError: '',
auditFilterFrontend: 'all',
auditFilterStatus: 'all',
get filteredAuditRoutes() {
return this.auditRoutes.filter(r => {
if (this.auditFilterFrontend !== 'all' && r.frontend !== this.auditFilterFrontend) return false;
if (this.auditFilterStatus !== 'all' && r.status !== this.auditFilterStatus) return false;
return true;
});
},
async runPermissionsAudit() {
this.auditLoading = true;
this.auditError = '';
try {
const resp = await apiClient.get('/admin/diagnostics/permissions-audit');
this.auditRoutes = resp.routes;
this.auditSummary = resp.summary;
} catch (e) {
this.auditError = e.message || 'Failed to run audit';
}
this.auditLoading = false;
},
}; };
} }
</script> </script>