Working state before icon/utils fixes - Oct 22
This commit is contained in:
128
app/api/deps.py
128
app/api/deps.py
@@ -1,13 +1,18 @@
|
||||
# app/api/deps.py
|
||||
"""Summary description ....
|
||||
"""
|
||||
Authentication dependencies for FastAPI routes.
|
||||
|
||||
This module provides classes and functions for:
|
||||
- ....
|
||||
- ....
|
||||
- ....
|
||||
Implements dual token storage pattern:
|
||||
- Checks Authorization header first (for API calls from JavaScript)
|
||||
- Falls back to cookie (for browser page navigation)
|
||||
|
||||
This allows:
|
||||
- JavaScript API calls: Use localStorage + Authorization header
|
||||
- Browser page loads: Use HTTP-only cookies
|
||||
"""
|
||||
|
||||
from fastapi import Depends
|
||||
from typing import Optional
|
||||
from fastapi import Depends, Request, Cookie
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -16,7 +21,12 @@ from middleware.auth import AuthManager
|
||||
from middleware.rate_limiter import RateLimiter
|
||||
from models.database.vendor import Vendor
|
||||
from models.database.user import User
|
||||
from app.exceptions import (AdminRequiredException, VendorNotFoundException, UnauthorizedVendorAccessException)
|
||||
from app.exceptions import (
|
||||
AdminRequiredException,
|
||||
VendorNotFoundException,
|
||||
UnauthorizedVendorAccessException,
|
||||
InvalidTokenException
|
||||
)
|
||||
|
||||
# Set auto_error=False to prevent automatic 403 responses
|
||||
security = HTTPBearer(auto_error=False)
|
||||
@@ -25,30 +35,107 @@ rate_limiter = RateLimiter()
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db),
|
||||
request: Request,
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
admin_token: Optional[str] = Cookie(None), # Check admin_token cookie
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get current authenticated user."""
|
||||
# Check if credentials are provided
|
||||
if not credentials:
|
||||
from app.exceptions.auth import InvalidTokenException
|
||||
raise InvalidTokenException("Authorization header required")
|
||||
"""
|
||||
Get current authenticated user.
|
||||
|
||||
return auth_manager.get_current_user(db, credentials)
|
||||
Checks for token in this priority order:
|
||||
1. Authorization header (for API calls from JavaScript)
|
||||
2. admin_token cookie (for browser page navigation)
|
||||
|
||||
This dual approach supports:
|
||||
- API calls: JavaScript adds token from localStorage to Authorization header
|
||||
- Page navigation: Browser automatically sends cookie
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
credentials: Optional Bearer token from Authorization header
|
||||
admin_token: Optional token from cookie
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
User: Authenticated user object
|
||||
|
||||
Raises:
|
||||
InvalidTokenException: If no token found or token invalid
|
||||
"""
|
||||
token = None
|
||||
token_source = None
|
||||
|
||||
# Priority 1: Authorization header (API calls from JavaScript)
|
||||
if credentials:
|
||||
token = credentials.credentials
|
||||
token_source = "header"
|
||||
|
||||
# Priority 2: Cookie (browser page navigation)
|
||||
elif admin_token:
|
||||
token = admin_token
|
||||
token_source = "cookie"
|
||||
|
||||
# No token found in either location
|
||||
if not token:
|
||||
raise InvalidTokenException("Authorization header or cookie required")
|
||||
|
||||
# Log token source for debugging
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"Token found in {token_source} for {request.url.path}")
|
||||
|
||||
# Create a mock credentials object for auth_manager
|
||||
mock_credentials = HTTPAuthorizationCredentials(
|
||||
scheme="Bearer",
|
||||
credentials=token
|
||||
)
|
||||
|
||||
return auth_manager.get_current_user(db, mock_credentials)
|
||||
|
||||
|
||||
def get_current_admin_user(current_user: User = Depends(get_current_user)):
|
||||
"""Require admin user."""
|
||||
"""
|
||||
Require admin user.
|
||||
|
||||
This dependency ensures the current user has admin role.
|
||||
Used for protecting admin-only routes.
|
||||
|
||||
Args:
|
||||
current_user: User object from get_current_user dependency
|
||||
|
||||
Returns:
|
||||
User: Admin user object
|
||||
|
||||
Raises:
|
||||
AdminRequiredException: If user is not an admin
|
||||
"""
|
||||
return auth_manager.require_admin(current_user)
|
||||
|
||||
|
||||
def get_user_vendor(
|
||||
vendor_code: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
vendor_code: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get vendor and verify user ownership."""
|
||||
"""
|
||||
Get vendor and verify user ownership.
|
||||
|
||||
Ensures the current user has access to the specified vendor.
|
||||
Admin users can access any vendor, regular users only their own.
|
||||
|
||||
Args:
|
||||
vendor_code: Vendor code to look up
|
||||
current_user: Current authenticated user
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Vendor: Vendor object if user has access
|
||||
|
||||
Raises:
|
||||
VendorNotFoundException: If vendor doesn't exist
|
||||
UnauthorizedVendorAccessException: If user doesn't have access
|
||||
"""
|
||||
vendor = db.query(Vendor).filter(Vendor.vendor_code == vendor_code.upper()).first()
|
||||
if not vendor:
|
||||
raise VendorNotFoundException(vendor_code)
|
||||
@@ -57,4 +144,3 @@ def get_user_vendor(
|
||||
raise UnauthorizedVendorAccessException(vendor_code, current_user.id)
|
||||
|
||||
return vendor
|
||||
|
||||
|
||||
@@ -8,9 +8,10 @@ This module combines all admin-related API endpoints:
|
||||
- User management (status, roles)
|
||||
- Dashboard and statistics
|
||||
- Marketplace monitoring
|
||||
- Audit logging (NEW)
|
||||
- Platform settings (NEW)
|
||||
- Notifications and alerts (NEW)
|
||||
- Audit logging
|
||||
- Platform settings
|
||||
- Notifications and alerts
|
||||
- HTML Pages - Server-rendered pages using Jinja2
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
@@ -25,7 +26,8 @@ from . import (
|
||||
monitoring,
|
||||
audit,
|
||||
settings,
|
||||
notifications
|
||||
notifications,
|
||||
pages
|
||||
)
|
||||
|
||||
# Create admin router
|
||||
@@ -51,7 +53,7 @@ router.include_router(marketplace.router, tags=["admin-marketplace"])
|
||||
# router.include_router(monitoring.router, tags=["admin-monitoring"])
|
||||
|
||||
# ============================================================================
|
||||
# NEW: Admin Models Integration
|
||||
# Admin Models Integration
|
||||
# ============================================================================
|
||||
|
||||
# Include audit logging endpoints
|
||||
@@ -63,6 +65,12 @@ router.include_router(settings.router, tags=["admin-settings"])
|
||||
# Include notifications and alerts endpoints
|
||||
router.include_router(notifications.router, tags=["admin-notifications"])
|
||||
|
||||
# ============================================================================
|
||||
# HTML Page Routes (Jinja2 Templates)
|
||||
# ============================================================================
|
||||
|
||||
# Include HTML page routes (these return rendered templates, not JSON)
|
||||
router.include_router(pages.router, tags=["admin-pages"])
|
||||
|
||||
# Export the router
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -2,32 +2,42 @@
|
||||
"""
|
||||
Admin authentication endpoints.
|
||||
|
||||
This module provides:
|
||||
- Admin user login
|
||||
- Admin token validation
|
||||
- Admin-specific authentication logic
|
||||
Implements dual token storage:
|
||||
- Sets HTTP-only cookie for browser page navigation
|
||||
- Returns token in response for localStorage (API calls)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.auth_service import auth_service
|
||||
from app.exceptions import InvalidCredentialsException
|
||||
from models.schema.auth import LoginResponse, UserLogin
|
||||
from models.schema.auth import LoginResponse, UserLogin, UserResponse
|
||||
from models.database.user import User
|
||||
from app.api.deps import get_current_admin_user
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter(prefix="/auth")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
def admin_login(user_credentials: UserLogin, db: Session = Depends(get_db)):
|
||||
def admin_login(
|
||||
user_credentials: UserLogin,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Admin login endpoint.
|
||||
|
||||
Only allows users with 'admin' role to login.
|
||||
Returns JWT token for authenticated admin users.
|
||||
|
||||
Sets token in two places:
|
||||
1. HTTP-only cookie (for browser page navigation)
|
||||
2. Response body (for localStorage and API calls)
|
||||
"""
|
||||
# Authenticate user
|
||||
login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
|
||||
@@ -39,6 +49,20 @@ def admin_login(user_credentials: UserLogin, db: Session = Depends(get_db)):
|
||||
|
||||
logger.info(f"Admin login successful: {login_result['user'].username}")
|
||||
|
||||
# Set HTTP-only cookie for browser navigation
|
||||
response.set_cookie(
|
||||
key="admin_token",
|
||||
value=login_result["token_data"]["access_token"],
|
||||
httponly=True, # JavaScript cannot access (XSS protection)
|
||||
secure=False, # Set to True in production (requires HTTPS)
|
||||
samesite="lax", # CSRF protection
|
||||
max_age=login_result["token_data"]["expires_in"], # Match JWT expiry
|
||||
path="/", # Available for all routes
|
||||
)
|
||||
|
||||
logger.debug(f"Set admin_token cookie with {login_result['token_data']['expires_in']}s expiry")
|
||||
|
||||
# Also return token in response for localStorage (API calls)
|
||||
return LoginResponse(
|
||||
access_token=login_result["token_data"]["access_token"],
|
||||
token_type=login_result["token_data"]["token_type"],
|
||||
@@ -47,12 +71,40 @@ def admin_login(user_credentials: UserLogin, db: Session = Depends(get_db)):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_current_admin(current_user: User = Depends(get_current_admin_user)):
|
||||
"""
|
||||
Get current authenticated admin user.
|
||||
|
||||
This endpoint validates the token and ensures the user has admin privileges.
|
||||
Returns the current user's information.
|
||||
|
||||
Token can come from:
|
||||
- Authorization header (API calls)
|
||||
- admin_token cookie (browser navigation)
|
||||
"""
|
||||
logger.info(f"Admin user info requested: {current_user.username}")
|
||||
|
||||
# Pydantic will automatically serialize the User model to UserResponse
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def admin_logout():
|
||||
def admin_logout(response: Response):
|
||||
"""
|
||||
Admin logout endpoint.
|
||||
|
||||
Client should remove token from storage.
|
||||
Server-side token invalidation can be implemented here if needed.
|
||||
Clears the admin_token cookie.
|
||||
Client should also remove token from localStorage.
|
||||
"""
|
||||
logger.info("Admin logout")
|
||||
|
||||
# Clear the cookie
|
||||
response.delete_cookie(
|
||||
key="admin_token",
|
||||
path="/",
|
||||
)
|
||||
|
||||
logger.debug("Deleted admin_token cookie")
|
||||
|
||||
return {"message": "Logged out successfully"}
|
||||
|
||||
110
app/api/v1/admin/pages.py
Normal file
110
app/api/v1/admin/pages.py
Normal file
@@ -0,0 +1,110 @@
|
||||
# app/api/v1/admin/pages.py
|
||||
"""
|
||||
Admin HTML page routes using Jinja2 templates.
|
||||
|
||||
These routes return rendered HTML pages (response_class=HTMLResponse).
|
||||
Separate from other admin routes which return JSON data.
|
||||
|
||||
Routes:
|
||||
- GET / - Admin root (redirects to login)
|
||||
- GET /login - Admin login page (no auth required)
|
||||
- GET /dashboard - Admin dashboard (requires auth)
|
||||
- GET /vendors - Vendor management page (requires auth)
|
||||
- GET /users - User management page (requires auth)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_user, get_db
|
||||
from models.database.user import User
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
@router.get("/", response_class=RedirectResponse, include_in_schema=False)
|
||||
async def admin_root():
|
||||
"""
|
||||
Redirect /admin/ to /admin/login.
|
||||
|
||||
This is the simplest approach:
|
||||
- Unauthenticated users: see login form
|
||||
- Authenticated users: login page clears token and shows form
|
||||
(they can manually navigate to dashboard if needed)
|
||||
|
||||
Alternative: Could redirect to /admin/dashboard and let auth
|
||||
dependency handle the redirect, but that's an extra hop.
|
||||
"""
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_login_page(request: Request):
|
||||
"""
|
||||
Render admin login page.
|
||||
No authentication required.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"admin/login.html",
|
||||
{"request": request}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_dashboard_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Render admin dashboard page.
|
||||
Requires admin authentication - will redirect to login if not authenticated.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"admin/dashboard.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": current_user,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/vendors", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_vendors_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Render vendors management page.
|
||||
Requires admin authentication.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"admin/vendors.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": current_user,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_users_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Render users management page.
|
||||
Requires admin authentication.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"admin/users.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": current_user,
|
||||
}
|
||||
)
|
||||
@@ -1,10 +1,11 @@
|
||||
# app/core/database.py
|
||||
"""Summary description ....
|
||||
"""
|
||||
Database configuration and session management.
|
||||
|
||||
This module provides classes and functions for:
|
||||
- ....
|
||||
- ....
|
||||
- ....
|
||||
- Database engine creation and configuration
|
||||
- Session management with connection pooling
|
||||
- Database dependency for FastAPI routes
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -21,16 +22,19 @@ Base = declarative_base()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Database dependency with connection pooling
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Get database object."""
|
||||
"""
|
||||
Database session dependency for FastAPI routes.
|
||||
|
||||
Yields a database session and ensures proper cleanup.
|
||||
Handles exceptions and rolls back transactions on error.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
except Exception as e:
|
||||
logger.error(f"Health check failed: {e}")
|
||||
logger.error(f"Database session error: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# app/exceptions/__init__.py
|
||||
"""
|
||||
Custom exception classes for the LetzVendor API.
|
||||
Custom exception classes for the API.
|
||||
|
||||
This module provides frontend-friendly exceptions with consistent error codes,
|
||||
messages, and HTTP status mappings.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# app/exceptions/base.py
|
||||
"""
|
||||
Base exception classes for the LetzVendor application.
|
||||
Base exception classes for the application.
|
||||
|
||||
This module provides classes and functions for:
|
||||
- Base exception class with consistent error formatting
|
||||
@@ -12,7 +12,7 @@ from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
class LetzShopException(Exception):
|
||||
"""Base exception class for all LetzVendor custom exceptions."""
|
||||
"""Base exception class for all custom exceptions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -14,7 +14,7 @@ from typing import Union
|
||||
|
||||
from fastapi import Request, HTTPException
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
|
||||
from .base import LetzShopException
|
||||
|
||||
@@ -26,7 +26,28 @@ def setup_exception_handlers(app):
|
||||
|
||||
@app.exception_handler(LetzShopException)
|
||||
async def custom_exception_handler(request: Request, exc: LetzShopException):
|
||||
"""Handle custom LetzVendor exceptions."""
|
||||
"""Handle custom exceptions."""
|
||||
|
||||
# Special handling for 401 on HTML page requests (redirect to login)
|
||||
if exc.status_code == 401 and _is_html_page_request(request):
|
||||
logger.info(
|
||||
f"401 on HTML page request - redirecting to login: {request.url.path}",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"accept": request.headers.get("accept", ""),
|
||||
"method": request.method
|
||||
}
|
||||
)
|
||||
|
||||
# Redirect to appropriate login page
|
||||
if request.url.path.startswith("/admin"):
|
||||
logger.debug("Redirecting to /admin/login")
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
elif "/vendor/" in request.url.path:
|
||||
logger.debug("Redirecting to /vendor/login")
|
||||
return RedirectResponse(url="/vendor/login", status_code=302)
|
||||
# If neither, fall through to JSON response
|
||||
logger.debug("No specific redirect path matched, returning JSON")
|
||||
|
||||
logger.error(
|
||||
f"Custom exception in {request.method} {request.url}: "
|
||||
@@ -162,6 +183,51 @@ def setup_exception_handlers(app):
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_html_page_request(request: Request) -> bool:
|
||||
"""
|
||||
Check if the request is for an HTML page (not an API endpoint).
|
||||
|
||||
More precise detection:
|
||||
- Must NOT have /api/ in path
|
||||
- Must be GET request
|
||||
- Must explicitly accept text/html
|
||||
- Must not already be on login page
|
||||
"""
|
||||
logger.debug(
|
||||
f"Checking if HTML page request: {request.url.path}",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"method": request.method,
|
||||
"accept": request.headers.get("accept", "")
|
||||
}
|
||||
)
|
||||
|
||||
# Don't redirect API calls
|
||||
if "/api/" in request.url.path:
|
||||
logger.debug("Not HTML page: API endpoint")
|
||||
return False
|
||||
|
||||
# Don't redirect if already on login page
|
||||
if request.url.path.endswith("/login"):
|
||||
logger.debug("Not HTML page: Already on login page")
|
||||
return False
|
||||
|
||||
# Only redirect GET requests (page loads)
|
||||
if request.method != "GET":
|
||||
logger.debug(f"Not HTML page: Method is {request.method}, not GET")
|
||||
return False
|
||||
|
||||
# MUST explicitly accept HTML (strict check)
|
||||
accept_header = request.headers.get("accept", "")
|
||||
if "text/html" not in accept_header:
|
||||
logger.debug(f"Not HTML page: Accept header doesn't include text/html: {accept_header}")
|
||||
return False
|
||||
|
||||
logger.debug("IS HTML page request - will redirect on 401")
|
||||
return True
|
||||
|
||||
|
||||
# Utility functions for common exception scenarios
|
||||
def raise_not_found(resource_type: str, identifier: str) -> None:
|
||||
"""Convenience function to raise ResourceNotFoundException."""
|
||||
|
||||
@@ -13,31 +13,31 @@ router = APIRouter(include_in_schema=False)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ADMIN ROUTES
|
||||
# ADMIN ROUTES - DISABLED (Now using Jinja2 templates in pages.py)
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/admin/")
|
||||
@router.get("/admin/login")
|
||||
async def admin_login():
|
||||
"""Serve admin login page"""
|
||||
return FileResponse("static/admin/login.html")
|
||||
# @router.get("/admin/")
|
||||
# @router.get("/admin/login")
|
||||
# async def admin_login():
|
||||
# """Serve admin login page"""
|
||||
# return FileResponse("static/admin/login.html")
|
||||
|
||||
|
||||
@router.get("/admin/dashboard")
|
||||
async def admin_dashboard():
|
||||
"""Serve admin dashboard page"""
|
||||
return FileResponse("static/admin/dashboard.html")
|
||||
# @router.get("/admin/dashboard")
|
||||
# async def admin_dashboard():
|
||||
# """Serve admin dashboard page"""
|
||||
# return FileResponse("static/admin/dashboard.html")
|
||||
|
||||
|
||||
@router.get("/admin/vendors")
|
||||
async def admin_vendors():
|
||||
"""Serve admin vendors management page"""
|
||||
return FileResponse("static/admin/vendors.html")
|
||||
# @router.get("/admin/vendors")
|
||||
# async def admin_vendors():
|
||||
# """Serve admin vendors management page"""
|
||||
# return FileResponse("static/admin/vendors.html")
|
||||
|
||||
@router.get("/admin/vendor-edit")
|
||||
async def admin_vendor_edit():
|
||||
"""Serve admin vendor edit page"""
|
||||
return FileResponse("static/admin/vendor-edit.html")
|
||||
# @router.get("/admin/vendor-edit")
|
||||
# async def admin_vendor_edit():
|
||||
# """Serve admin vendor edit page"""
|
||||
# return FileResponse("static/admin/vendor-edit.html")
|
||||
|
||||
# ============================================================================
|
||||
# VENDOR ROUTES (with vendor code in path)
|
||||
|
||||
60
app/templates/admin/base.html
Normal file
60
app/templates/admin/base.html
Normal file
@@ -0,0 +1,60 @@
|
||||
{# app/templates/admin/base.html #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'theme-dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{% block title %}Admin Panel{% endblock %} - Multi-Tenant Platform</title>
|
||||
|
||||
<!-- Fonts -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
|
||||
|
||||
<!-- Alpine Cloak -->
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body x-cloak>
|
||||
<div class="flex h-screen bg-gray-50 dark:bg-gray-900" :class="{ 'overflow-hidden': isSideMenuOpen }">
|
||||
<!-- Sidebar (server-side included) -->
|
||||
{% include 'partials/sidebar.html' %}
|
||||
|
||||
<div class="flex flex-col flex-1 w-full">
|
||||
<!-- Header (server-side included) -->
|
||||
{% include 'partials/header.html' %}
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="h-full overflow-y-auto">
|
||||
<div class="container px-6 mx-auto grid">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Core Scripts - Loaded in STRICT ORDER -->
|
||||
|
||||
<!-- 1. Icons FIRST (defines $icon magic) -->
|
||||
<script src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
|
||||
|
||||
<!-- 2. Base Alpine Data (defines sidebar/header state) -->
|
||||
<script src="{{ url_for('static', path='admin/js/init-alpine.js') }}"></script>
|
||||
|
||||
<!-- 3. API Client -->
|
||||
<script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
|
||||
|
||||
<!-- 4. Utils -->
|
||||
<script src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
|
||||
|
||||
<!-- 5. Alpine.js v3 (deferred to allow DOM to load) -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- 6. Page-specific scripts -->
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
172
app/templates/admin/dashboard.html
Normal file
172
app/templates/admin/dashboard.html
Normal file
@@ -0,0 +1,172 @@
|
||||
{# app/templates/admin/dashboard.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminDashboard(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header with Refresh Button -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Dashboard
|
||||
</h2>
|
||||
<button
|
||||
@click="refresh()"
|
||||
:disabled="loading"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading dashboard...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div x-show="error && !loading" class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Error loading dashboard</p>
|
||||
<p class="text-sm" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Card: Total Vendors -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
||||
<span x-html="$icon('user-group', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Total Vendors
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.totalVendors">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Active Users -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('users', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Active Users
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.activeUsers">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Verified Vendors -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||
<span x-html="$icon('badge-check', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Verified Vendors
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.verifiedVendors">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Import Jobs -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-teal-500 bg-teal-100 rounded-full dark:text-teal-100 dark:bg-teal-500">
|
||||
<span x-html="$icon('download', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Import Jobs
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.importJobs">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Vendors Table -->
|
||||
<div x-show="!loading" class="w-full overflow-hidden rounded-lg shadow-xs">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full whitespace-no-wrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Vendor</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Created</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-if="recentVendors.length === 0">
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
|
||||
<div class="flex flex-col items-center">
|
||||
<span x-html="$icon('user-group', 'w-12 h-12 mb-2 text-gray-300')"></span>
|
||||
<p>No vendors yet.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<template x-for="vendor in recentVendors" :key="vendor.vendor_code">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center text-sm">
|
||||
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block">
|
||||
<div class="absolute inset-0 rounded-full bg-purple-100 dark:bg-purple-600 flex items-center justify-center">
|
||||
<span class="text-xs font-semibold text-purple-600 dark:text-purple-100" x-text="vendor.name?.charAt(0).toUpperCase() || '?'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold" x-text="vendor.name"></p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="vendor.vendor_code"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span class="inline-flex items-center px-2 py-1 font-semibold leading-tight rounded-full"
|
||||
:class="vendor.is_verified ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-orange-700 bg-orange-100 dark:text-white dark:bg-orange-600'">
|
||||
<span x-show="vendor.is_verified" x-html="$icon('badge-check', 'w-3 h-3 mr-1')"></span>
|
||||
<span x-text="vendor.is_verified ? 'Verified' : 'Pending'"></span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm" x-text="formatDate(vendor.created_at)">
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center space-x-2 text-sm">
|
||||
<button
|
||||
@click="viewVendor(vendor.vendor_code)"
|
||||
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-gray-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
||||
title="View vendor"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('static', path='admin/js/dashboard.js') }}"></script>
|
||||
{% endblock %}
|
||||
109
app/templates/admin/login.html
Normal file
109
app/templates/admin/login.html
Normal file
@@ -0,0 +1,109 @@
|
||||
{# app/templates/admin/login.html #}
|
||||
<!DOCTYPE html>
|
||||
<html :class="{ 'theme-dark': dark }" x-data="adminLogin()" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin Login - Multi-Tenant Platform</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
||||
<div class="flex-1 h-full max-w-4xl mx-auto overflow-hidden bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||
<div class="flex flex-col overflow-y-auto md:flex-row">
|
||||
<div class="h-32 md:h-auto md:w-1/2">
|
||||
<img aria-hidden="true" class="object-cover w-full h-full dark:hidden"
|
||||
src="{{ url_for('static', path='admin/img/login-office.jpeg') }}" alt="Office" />
|
||||
<img aria-hidden="true" class="hidden object-cover w-full h-full dark:block"
|
||||
src="{{ url_for('static', path='admin/img/login-office-dark.jpeg') }}" alt="Office" />
|
||||
</div>
|
||||
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
|
||||
<div class="w-full">
|
||||
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Admin Login
|
||||
</h1>
|
||||
|
||||
<!-- Alert Messages -->
|
||||
<div x-show="error" x-text="error"
|
||||
class="px-4 py-3 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800"
|
||||
x-transition></div>
|
||||
|
||||
<div x-show="success" x-text="success"
|
||||
class="px-4 py-3 mb-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800"
|
||||
x-transition></div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form @submit.prevent="handleLogin">
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Username</span>
|
||||
<input x-model="credentials.username"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
||||
:class="{ 'border-red-600': errors.username }"
|
||||
placeholder="Enter your username"
|
||||
autocomplete="username"
|
||||
required />
|
||||
<span x-show="errors.username" x-text="errors.username"
|
||||
class="text-xs text-red-600 dark:text-red-400"></span>
|
||||
</label>
|
||||
|
||||
<label class="block mt-4 text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">Password</span>
|
||||
<input x-model="credentials.password"
|
||||
:disabled="loading"
|
||||
@input="clearErrors"
|
||||
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
||||
:class="{ 'border-red-600': errors.password }"
|
||||
placeholder="***************"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required />
|
||||
<span x-show="errors.password" x-text="errors.password"
|
||||
class="text-xs text-red-600 dark:text-red-400"></span>
|
||||
</label>
|
||||
|
||||
<button type="submit" :disabled="loading"
|
||||
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!loading">Log in</span>
|
||||
<span x-show="loading">
|
||||
<svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Signing in...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<hr class="my-8" />
|
||||
|
||||
<p class="mt-4">
|
||||
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline"
|
||||
href="#">
|
||||
Forgot your password?
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||
href="/">
|
||||
← Back to Platform
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
|
||||
<script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
|
||||
<script src="{{ url_for('static', path='admin/js/login.js') }}"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
114
app/templates/partials/header.html
Normal file
114
app/templates/partials/header.html
Normal file
@@ -0,0 +1,114 @@
|
||||
<!-- Top header bar with search, theme toggle, notifications, profile -->
|
||||
<header class="z-10 py-4 bg-white shadow-md dark:bg-gray-800">
|
||||
<div class="container flex items-center justify-between h-full px-6 mx-auto text-purple-600 dark:text-purple-300">
|
||||
<!-- Mobile hamburger -->
|
||||
<button class="p-1 mr-5 -ml-1 rounded-md md:hidden focus:outline-none focus:shadow-outline-purple"
|
||||
@click="toggleSideMenu" aria-label="Menu">
|
||||
<span x-html="$icon('menu', 'w-6 h-6')"></span>
|
||||
</button>
|
||||
|
||||
<!-- Search input -->
|
||||
<div class="flex justify-center flex-1 lg:mr-32">
|
||||
<div class="relative w-full max-w-xl mr-6 focus-within:text-purple-500">
|
||||
<div class="absolute inset-y-0 flex items-center pl-2">
|
||||
<span x-html="$icon('search', 'w-4 h-4')"></span>
|
||||
</div>
|
||||
<input class="w-full pl-8 pr-2 text-sm text-gray-700 placeholder-gray-600 bg-gray-100 border-0 rounded-md dark:placeholder-gray-500 dark:focus:shadow-outline-gray dark:focus:placeholder-gray-600 dark:bg-gray-700 dark:text-gray-200 focus:placeholder-gray-500 focus:bg-white focus:border-purple-300 focus:outline-none focus:shadow-outline-purple form-input"
|
||||
type="text" placeholder="Search for projects" aria-label="Search"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="flex items-center flex-shrink-0 space-x-6">
|
||||
<!-- Theme toggler -->
|
||||
<li class="flex">
|
||||
<button class="rounded-md focus:outline-none focus:shadow-outline-purple"
|
||||
@click="toggleTheme" aria-label="Toggle color mode">
|
||||
<template x-if="!dark">
|
||||
<span x-html="$icon('moon', 'w-5 h-5')"></span>
|
||||
</template>
|
||||
<template x-if="dark">
|
||||
<span x-html="$icon('sun', 'w-5 h-5')"></span>
|
||||
</template>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<!-- Notifications menu -->
|
||||
<li class="relative">
|
||||
<button class="relative align-middle rounded-md focus:outline-none focus:shadow-outline-purple"
|
||||
@click="toggleNotificationsMenu" @keydown.escape="closeNotificationsMenu"
|
||||
aria-label="Notifications" aria-haspopup="true">
|
||||
<span x-html="$icon('bell', 'w-5 h-5')"></span>
|
||||
<span aria-hidden="true" class="absolute top-0 right-0 inline-block w-3 h-3 transform translate-x-1 -translate-y-1 bg-red-600 border-2 border-white rounded-full dark:border-gray-800"></span>
|
||||
</button>
|
||||
|
||||
<template x-if="isNotificationsMenuOpen">
|
||||
<ul x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
@click.away="closeNotificationsMenu"
|
||||
@keydown.escape="closeNotificationsMenu"
|
||||
class="absolute right-0 w-56 p-2 mt-2 space-y-2 text-gray-600 bg-white border border-gray-100 rounded-md shadow-md dark:text-gray-300 dark:border-gray-700 dark:bg-gray-700">
|
||||
<li class="flex">
|
||||
<a class="inline-flex items-center justify-between w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200" href="#">
|
||||
<span>Messages</span>
|
||||
<span class="inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-red-600 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-600">13</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="flex">
|
||||
<a class="inline-flex items-center justify-between w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200" href="#">
|
||||
<span>Sales</span>
|
||||
<span class="inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-red-600 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-600">2</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="flex">
|
||||
<a class="inline-flex items-center justify-between w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200" href="#">
|
||||
<span>Alerts</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</li>
|
||||
|
||||
<!-- Profile menu -->
|
||||
<li class="relative">
|
||||
<button class="align-middle rounded-full focus:shadow-outline-purple focus:outline-none"
|
||||
@click="toggleProfileMenu" @keydown.escape="closeProfileMenu"
|
||||
aria-label="Account" aria-haspopup="true">
|
||||
<img class="object-cover w-8 h-8 rounded-full"
|
||||
src="https://images.unsplash.com/photo-1502378735452-bc7d86632805?ixlib=rb-0.3.5&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=200&fit=max&s=aa3a807e1bbdfd4364d1f449eaa96d82"
|
||||
alt="" aria-hidden="true"/>
|
||||
</button>
|
||||
|
||||
<template x-if="isProfileMenuOpen">
|
||||
<ul x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
@click.away="closeProfileMenu"
|
||||
@keydown.escape="closeProfileMenu"
|
||||
class="absolute right-0 w-56 p-2 mt-2 space-y-2 text-gray-600 bg-white border border-gray-100 rounded-md shadow-md dark:border-gray-700 dark:text-gray-300 dark:bg-gray-700"
|
||||
aria-label="submenu">
|
||||
<li class="flex">
|
||||
<a class="inline-flex items-center w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200" href="#">
|
||||
<span x-html="$icon('user', 'w-4 h-4 mr-3')"></span>
|
||||
<span>Profile</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="flex">
|
||||
<a class="inline-flex items-center w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200" href="#">
|
||||
<span x-html="$icon('cog', 'w-4 h-4 mr-3')"></span>
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="flex">
|
||||
<a class="inline-flex items-center w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
href="/admin/login"> <!-- ← Changed from /static/admin/login.html -->
|
||||
<span x-html="$icon('logout', 'w-4 h-4 mr-3')"></span>
|
||||
<span>Log out</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
120
app/templates/partials/sidebar.html
Normal file
120
app/templates/partials/sidebar.html
Normal file
@@ -0,0 +1,120 @@
|
||||
<!-- app/templates/partials/sidebar.html -->
|
||||
<!-- Desktop sidebar -->
|
||||
<aside class="z-20 hidden w-64 overflow-y-auto bg-white dark:bg-gray-800 md:block flex-shrink-0">
|
||||
<div class="py-4 text-gray-500 dark:text-gray-400">
|
||||
<a class="ml-6 text-lg font-bold text-gray-800 dark:text-gray-200" href="/admin/dashboard">
|
||||
Admin Portal
|
||||
</a>
|
||||
<ul class="mt-6">
|
||||
<li class="relative px-6 py-3">
|
||||
<span x-show="currentPage === 'dashboard'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
|
||||
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
:class="currentPage === 'dashboard' ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||
href="/admin/dashboard">
|
||||
<span x-html="$icon('home')"></span>
|
||||
<span class="ml-4">Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li class="relative px-6 py-3">
|
||||
<span x-show="currentPage === 'vendors'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
|
||||
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
:class="currentPage === 'vendors' ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||
href="/admin/vendors">
|
||||
<span x-html="$icon('shopping-bag')"></span>
|
||||
<span class="ml-4">Vendors</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="relative px-6 py-3">
|
||||
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
href="/admin/users">
|
||||
<span x-html="$icon('users')"></span>
|
||||
<span class="ml-4">Users</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="relative px-6 py-3">
|
||||
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
href="#">
|
||||
<span x-html="$icon('cube')"></span>
|
||||
<span class="ml-4">Import Jobs</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="px-6 my-6">
|
||||
<button class="flex items-center justify-between w-full px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
|
||||
Create vendor
|
||||
<span class="ml-2" aria-hidden="true">+</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile sidebar -->
|
||||
<div x-show="isSideMenuOpen"
|
||||
x-transition:enter="transition ease-in-out duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in-out duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-10 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"></div>
|
||||
|
||||
<aside class="fixed inset-y-0 z-20 flex-shrink-0 w-64 mt-16 overflow-y-auto bg-white dark:bg-gray-800 md:hidden"
|
||||
x-show="isSideMenuOpen"
|
||||
x-transition:enter="transition ease-in-out duration-150"
|
||||
x-transition:enter-start="opacity-0 transform -translate-x-20"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in-out duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0 transform -translate-x-20"
|
||||
@click.away="closeSideMenu"
|
||||
@keydown.escape="closeSideMenu">
|
||||
<div class="py-4 text-gray-500 dark:text-gray-400">
|
||||
<a class="ml-6 text-lg font-bold text-gray-800 dark:text-gray-200" href="/admin/dashboard">
|
||||
Admin Portal
|
||||
</a>
|
||||
<ul class="mt-6">
|
||||
<li class="relative px-6 py-3">
|
||||
<span x-show="currentPage === 'dashboard'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
|
||||
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
:class="currentPage === 'dashboard' ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||
href="/admin/dashboard">
|
||||
<span x-html="$icon('home')"></span>
|
||||
<span class="ml-4">Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li class="relative px-6 py-3">
|
||||
<span x-show="currentPage === 'vendors'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
|
||||
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
:class="currentPage === 'vendors' ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||
href="/admin/vendors">
|
||||
<span x-html="$icon('shopping-bag')"></span>
|
||||
<span class="ml-4">Vendors</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="relative px-6 py-3">
|
||||
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
href="/admin/users">
|
||||
<span x-html="$icon('users')"></span>
|
||||
<span class="ml-4">Users</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="relative px-6 py-3">
|
||||
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
href="#">
|
||||
<span x-html="$icon('cube')"></span>
|
||||
<span class="ml-4">Import Jobs</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="px-6 my-6">
|
||||
<button class="flex items-center justify-between px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
|
||||
Create vendor
|
||||
<span class="ml-2" aria-hidden="true">+</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
Reference in New Issue
Block a user