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(
id="platform-debug",
label_key="dev_tools.menu.platform_debug",
label_key="dev_tools.menu.diagnostics",
icon="search",
route="/admin/platform-debug",
order=40,

View File

@@ -14,9 +14,20 @@
"components": "Komponenten",
"icons": "Icons",
"sql_query": "SQL Abfrage",
"platform_debug": "Plattform Debug",
"diagnostics": "Diagnostik",
"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": {
"title": "Übersetzungseditor",
"all_modules": "Alle Module",

View File

@@ -14,9 +14,20 @@
"components": "Components",
"icons": "Icons",
"sql_query": "SQL Query",
"platform_debug": "Platform Debug",
"diagnostics": "Diagnostics",
"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": {
"title": "Translation Editor",
"all_modules": "All Modules",

View File

@@ -14,9 +14,20 @@
"components": "Composants",
"icons": "Icônes",
"sql_query": "Requête SQL",
"platform_debug": "Debug Plateforme",
"diagnostics": "Diagnostics",
"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": {
"title": "Éditeur de traductions",
"all_modules": "Tous les modules",

View File

@@ -14,9 +14,20 @@
"components": "Komponenten",
"icons": "Icons",
"sql_query": "SQL Ufro",
"platform_debug": "Plattform Debug",
"diagnostics": "Diagnostik",
"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": {
"title": "Iwwersetzungseditor",
"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 app.modules.dev_tools.routes.api.admin_diagnostics import (
router as diagnostics_router,
)
from app.modules.dev_tools.routes.api.admin_platform_debug import (
router as platform_debug_router,
)
@@ -19,3 +22,4 @@ router = APIRouter()
router.include_router(sql_query_router, tags=["sql-query"])
router.include_router(platform_debug_router, tags=["platform-debug"])
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),
):
"""
Render platform resolution debug page.
Traces middleware pipeline for all URL patterns.
Render diagnostics hub page.
Provides platform trace, permissions audit, and other diagnostic tools.
"""
return templates.TemplateResponse(
"dev_tools/admin/platform-debug.html",

View File

@@ -1,137 +1,341 @@
{# app/modules/dev_tools/templates/dev_tools/admin/platform-debug.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 content %}
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Platform Resolution Trace</h1>
<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.
</p>
</div>
{{ page_header('Diagnostics', back_url='/admin/dashboard', back_label='Back to Dashboard') }}
<!-- Controls -->
<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">
<button @click="runAllTests()" :disabled="running"
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
<span x-show="!running">Run All Tests</span>
<span x-show="running">Running...</span>
</button>
<div class="flex gap-2">
<button @click="filterGroup = 'all'" :class="filterGroup === 'all' ? 'bg-gray-200 dark:bg-gray-600' : ''"
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">All</button>
<button @click="filterGroup = 'dev'" :class="filterGroup === 'dev' ? 'bg-gray-200 dark:bg-gray-600' : ''"
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Dev Path-Based</button>
<button @click="filterGroup = 'prod-domain'" :class="filterGroup === 'prod-domain' ? 'bg-gray-200 dark:bg-gray-600' : ''"
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Domain</button>
<button @click="filterGroup = 'prod-subdomain'" :class="filterGroup === 'prod-subdomain' ? 'bg-gray-200 dark:bg-gray-600' : ''"
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Subdomain</button>
<button @click="filterGroup = 'prod-custom'" :class="filterGroup === 'prod-custom' ? 'bg-gray-200 dark:bg-gray-600' : ''"
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 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>
</div>
<!-- Custom test -->
<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>
<div class="flex gap-3 items-end flex-wrap">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Host</label>
<input x-model="customHost" type="text" placeholder="localhost:8000"
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-56">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Path</label>
<input x-model="customPath" type="text" placeholder="/platforms/loyalty/store/WIZATECH/login"
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-80">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Body platform_code</label>
<input x-model="customPlatformCode" type="text" placeholder="(optional)"
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-40">
</div>
<button @click="runCustomTest()" class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 text-sm">
Trace
</button>
</div>
<!-- Custom result -->
<template x-if="customResult">
<div class="mt-4">
<div x-html="renderTrace(customResult)"></div>
</div>
</template>
</div>
<!-- ═══════════════════════════════════════════════════════════════════ -->
<!-- Main area — tool content panels -->
<!-- ═══════════════════════════════════════════════════════════════════ -->
<div class="flex-1 min-w-0">
<!-- Test Results -->
<div class="space-y-4">
<template x-for="test in filteredTests" :key="test.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs overflow-hidden">
<!-- Header -->
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 cursor-pointer flex items-center justify-between"
@click="test.expanded = !test.expanded">
<div class="flex items-center gap-3">
<!-- Status indicator -->
<div class="w-3 h-3 rounded-full"
:class="{
'bg-gray-300 dark:bg-gray-600': !test.result,
'bg-green-500': test.result && test.pass,
'bg-red-500': test.result && !test.pass,
'animate-pulse bg-yellow-400': test.running
}"></div>
<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': test.group === 'dev',
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': test.group === 'prod-domain',
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300': test.group === 'prod-subdomain',
'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300': test.group === 'prod-custom'
}" x-text="test.groupLabel"></span>
<div>
<span class="font-mono text-sm text-gray-900 dark:text-white" x-text="test.label"></span>
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2" x-text="test.description"></span>
<!-- ─────────────────────────────────────────────────────────── -->
<!-- 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">
Simulates the middleware pipeline for each URL pattern to trace how platform &amp; store context are resolved.
</p>
</div>
<!-- Controls -->
<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">
<button @click="runAllTests()" :disabled="running"
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
<span x-show="!running">Run All Tests</span>
<span x-show="running">Running...</span>
</button>
<div class="flex gap-2">
<button @click="filterGroup = 'all'" :class="filterGroup === 'all' ? 'bg-gray-200 dark:bg-gray-600' : ''"
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">All</button>
<button @click="filterGroup = 'dev'" :class="filterGroup === 'dev' ? 'bg-gray-200 dark:bg-gray-600' : ''"
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Dev Path-Based</button>
<button @click="filterGroup = 'prod-domain'" :class="filterGroup === 'prod-domain' ? 'bg-gray-200 dark:bg-gray-600' : ''"
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Domain</button>
<button @click="filterGroup = 'prod-subdomain'" :class="filterGroup === 'prod-subdomain' ? 'bg-gray-200 dark:bg-gray-600' : ''"
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Subdomain</button>
<button @click="filterGroup = 'prod-custom'" :class="filterGroup === 'prod-custom' ? 'bg-gray-200 dark:bg-gray-600' : ''"
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 class="flex items-center gap-3">
<template x-if="test.result">
<span class="text-xs font-mono px-2 py-1 rounded"
:class="test.pass ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'"
x-text="'platform=' + (test.result.login_platform_code || 'null')"></span>
</template>
<template x-if="test.result">
<button @click.stop="copyTrace(test)" title="Copy full trace"
class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
Copy
</button>
</template>
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="test.expanded ? 'rotate-180' : ''"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</div>
<!-- Detail -->
<div x-show="test.expanded" x-cloak class="px-4 py-3">
<template x-if="test.result">
<div x-html="renderTrace(test.result)"></div>
<!-- Custom test -->
<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>
<div class="flex gap-3 items-end flex-wrap">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Host</label>
<input x-model="customHost" type="text" placeholder="localhost:8000"
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-56">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Path</label>
<input x-model="customPath" type="text" placeholder="/platforms/loyalty/store/WIZATECH/login"
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-80">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Body platform_code</label>
<input x-model="customPlatformCode" type="text" placeholder="(optional)"
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-40">
</div>
<button @click="runCustomTest()" class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 text-sm">
Trace
</button>
</div>
<!-- Custom result -->
<template x-if="customResult">
<div class="mt-4">
<div x-html="renderTrace(customResult)"></div>
</div>
</template>
<template x-if="!test.result && !test.running">
<p class="text-sm text-gray-500 dark:text-gray-400">Click "Run All Tests" to trace this case.</p>
</template>
<template x-if="test.running">
<p class="text-sm text-yellow-600 dark:text-yellow-400">Running...</p>
</div>
<!-- Test Results -->
<div class="space-y-4">
<template x-for="test in filteredTests" :key="test.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs overflow-hidden">
<!-- Header -->
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 cursor-pointer flex items-center justify-between"
@click="test.expanded = !test.expanded">
<div class="flex items-center gap-3">
<!-- Status indicator -->
<div class="w-3 h-3 rounded-full"
:class="{
'bg-gray-300 dark:bg-gray-600': !test.result,
'bg-green-500': test.result && test.pass,
'bg-red-500': test.result && !test.pass,
'animate-pulse bg-yellow-400': test.running
}"></div>
<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': test.group === 'dev',
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': test.group === 'prod-domain',
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300': test.group === 'prod-subdomain',
'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300': test.group === 'prod-custom'
}" x-text="test.groupLabel"></span>
<div>
<span class="font-mono text-sm text-gray-900 dark:text-white" x-text="test.label"></span>
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2" x-text="test.description"></span>
</div>
</div>
<div class="flex items-center gap-3">
<template x-if="test.result">
<span class="text-xs font-mono px-2 py-1 rounded"
:class="test.pass ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'"
x-text="'platform=' + (test.result.login_platform_code || 'null')"></span>
</template>
<template x-if="test.result">
<button @click.stop="copyTrace(test)" title="Copy full trace"
class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
Copy
</button>
</template>
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="test.expanded ? 'rotate-180' : ''"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</div>
<!-- Detail -->
<div x-show="test.expanded" x-cloak class="px-4 py-3">
<template x-if="test.result">
<div x-html="renderTrace(test.result)"></div>
</template>
<template x-if="!test.result && !test.running">
<p class="text-sm text-gray-500 dark:text-gray-400">Click "Run All Tests" to trace this case.</p>
</template>
<template x-if="test.running">
<p class="text-sm text-yellow-600 dark:text-yellow-400">Running...</p>
</template>
</div>
</div>
</template>
</div>
</div>
</template>
<!-- ─────────────────────────────────────────────────────────── -->
<!-- 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>
<script>
function platformDebug() {
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,
filterGroup: 'all',
customHost: 'localhost:8000',
@@ -308,9 +512,6 @@ function platformDebug() {
},
// ── 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',
label: 'wizatech.shop /store/login',
@@ -460,6 +661,35 @@ function platformDebug() {
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>