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:
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
289
app/modules/dev_tools/routes/api/admin_diagnostics.py
Normal file
289
app/modules/dev_tools/routes/api/admin_diagnostics.py
Normal 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)
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 & store context are resolved.
|
Simulates the middleware pipeline for each URL pattern to trace how platform & 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user