Working state before icon/utils fixes - Oct 22

This commit is contained in:
2025-10-21 21:56:54 +02:00
parent a7d9d44a13
commit 5be47b91a2
39 changed files with 6017 additions and 508 deletions

View File

@@ -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

View File

@@ -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"]

View File

@@ -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
View 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,
}
)

View File

@@ -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:

View File

@@ -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.

View File

@@ -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,

View File

@@ -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."""

View File

@@ -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)

View 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>

View 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 %}

View 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>

View 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>

View 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>