refactor: migrate vendor APIs to token-based context and consolidate architecture

## Vendor-in-Token Architecture (Complete Migration)
- Migrate all vendor API endpoints from require_vendor_context() to token_vendor_id
- Update permission dependencies to extract vendor from JWT token
- Add vendor exceptions: VendorAccessDeniedException, VendorOwnerOnlyException,
  InsufficientVendorPermissionsException
- Shop endpoints retain require_vendor_context() for URL-based detection
- Add AUTH-004 architecture rule enforcing vendor context patterns
- Fix marketplace router missing /marketplace prefix

## Exception Pattern Fixes (API-003/API-004)
- Services raise domain exceptions, endpoints let them bubble up
- Add code_quality and content_page exception modules
- Move business logic from endpoints to services (admin, auth, content_page)
- Fix exception handling in admin, shop, and vendor endpoints

## Tailwind CSS Consolidation
- Consolidate CSS to per-area files (admin, vendor, shop, platform)
- Remove shared/cdn-fallback.html and shared/css/tailwind.min.css
- Update all templates to use area-specific Tailwind output files
- Remove Node.js config (package.json, postcss.config.js, tailwind.config.js)

## Documentation & Cleanup
- Update vendor-in-token-architecture.md with completed migration status
- Update architecture-rules.md with new rules
- Move migration docs to docs/development/migration/
- Remove duplicate/obsolete documentation files
- Merge pytest.ini settings into pyproject.toml

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-04 22:24:45 +01:00
parent 76f8a59954
commit 8a367077e1
85 changed files with 21787 additions and 134978 deletions

View File

@@ -95,10 +95,28 @@ api_endpoint_rules:
description: | description: |
Protected endpoints must use Depends() for authentication. Protected endpoints must use Depends() for authentication.
Use get_current_user, get_current_admin, etc. Use get_current_user, get_current_admin, etc.
Auto-excluded files:
- */auth.py - Authentication endpoints (login, logout, register) are intentionally public
Public endpoint markers (place on line before or after decorator):
- # public - Descriptive marker for intentionally unauthenticated endpoints
- # noqa: API-004 - Standard noqa style to suppress warning
Example:
# public - Stripe webhook receives external callbacks
@router.post("/webhook/stripe")
def stripe_webhook(request: Request):
...
pattern: pattern:
file_pattern: "app/api/v1/**/*.py" file_pattern: "app/api/v1/**/*.py"
required_if_not_public: required_if_not_public:
- "Depends(get_current_" - "Depends(get_current_"
auto_exclude_files:
- "*/auth.py"
public_markers:
- "# public"
- "# noqa: api-004"
- id: "API-005" - id: "API-005"
name: "Multi-tenant endpoints must scope queries to vendor_id" name: "Multi-tenant endpoints must scope queries to vendor_id"
@@ -466,11 +484,30 @@ template_rules:
- id: "TPL-001" - id: "TPL-001"
name: "Admin templates must extend admin/base.html" name: "Admin templates must extend admin/base.html"
severity: "error" severity: "error"
description: "All admin templates must extend the base template for consistency" description: |
All admin templates must extend the base template for consistency.
Auto-excluded files:
- login.html - Standalone login page (no sidebar/navigation)
- errors/*.html - Error pages extend errors/base.html instead
- test-*.html - Test/development templates
Standalone template markers (place in first 5 lines):
- {# standalone #} - Mark template as intentionally standalone
- {# noqa: TPL-001 #} - Standard noqa style to suppress error
- <!-- standalone --> - HTML comment style
pattern: pattern:
file_pattern: "app/templates/admin/**/*.html" file_pattern: "app/templates/admin/**/*.html"
required_patterns: required_patterns:
- "{% extends ['\"]admin/base\\.html['\"] %}" - "{% extends ['\"]admin/base\\.html['\"] %}"
auto_exclude_files:
- "login.html"
- "errors/"
- "test-"
standalone_markers:
- "{# standalone #}"
- "{# noqa: tpl-001 #}"
- "<!-- standalone -->"
exceptions: exceptions:
- "base.html" - "base.html"
- "partials/" - "partials/"
@@ -660,6 +697,37 @@ auth_rules:
file_pattern: "app/services/auth_service.py" file_pattern: "app/services/auth_service.py"
required: "bcrypt" required: "bcrypt"
- id: "AUTH-004"
name: "Vendor context pattern - use appropriate dependency for endpoint type"
severity: "error"
description: |
Two vendor context patterns exist - use the appropriate one:
1. SHOP ENDPOINTS (public, no authentication required):
- Use: vendor: Vendor = Depends(require_vendor_context())
- Vendor is detected from URL/subdomain/domain
- File pattern: app/api/v1/shop/**/*.py
- Mark as public with: # public
2. VENDOR API ENDPOINTS (authenticated):
- Use: current_user.token_vendor_id from JWT token
- Or use permission dependencies: require_vendor_permission(), require_vendor_owner
- These dependencies get vendor from token and set request.state.vendor
- File pattern: app/api/v1/vendor/**/*.py
DEPRECATED for vendor APIs:
- require_vendor_context() - only for shop endpoints
- getattr(request.state, "vendor", None) without permission dependency
See: docs/backend/vendor-in-token-architecture.md
pattern:
file_pattern: "app/api/v1/vendor/**/*.py"
anti_patterns:
- "require_vendor_context\\(\\)"
file_pattern: "app/api/v1/shop/**/*.py"
required_patterns:
- "require_vendor_context\\(\\)|# public"
# ============================================================================ # ============================================================================
# CODE QUALITY RULES # CODE QUALITY RULES
# ============================================================================ # ============================================================================

View File

@@ -1,7 +1,7 @@
# Wizamart Multi-Tenant E-Commerce Platform Makefile # Wizamart Multi-Tenant E-Commerce Platform Makefile
# Cross-platform compatible (Windows & Linux) # Cross-platform compatible (Windows & Linux)
.PHONY: install install-dev install-docs install-all dev test test-coverage lint format check docker-build docker-up docker-down clean help npm-install tailwind-dev tailwind-build arch-check arch-check-file arch-check-object .PHONY: install install-dev install-docs install-all dev test test-coverage lint format check docker-build docker-up docker-down clean help tailwind-install tailwind-dev tailwind-build tailwind-watch arch-check arch-check-file arch-check-object
# Detect OS # Detect OS
ifeq ($(OS),Windows_NT) ifeq ($(OS),Windows_NT)
@@ -282,29 +282,40 @@ docs-check:
$(PYTHON) -m mkdocs build --strict --verbose $(PYTHON) -m mkdocs build --strict --verbose
# ============================================================================= # =============================================================================
# FRONTEND / TAILWIND CSS # FRONTEND / TAILWIND CSS (Standalone CLI - No Node.js Required)
# ============================================================================= # =============================================================================
npm-install: # Tailwind CLI binary location
@echo "Installing npm dependencies..." TAILWIND_CLI := $(HOME)/.local/bin/tailwindcss
npm install
@echo "npm dependencies installed" tailwind-install:
@echo "Installing Tailwind CSS standalone CLI..."
@mkdir -p $(HOME)/.local/bin
@curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64
@chmod +x tailwindcss-linux-x64
@mv tailwindcss-linux-x64 $(TAILWIND_CLI)
@echo "Tailwind CLI installed: $$($(TAILWIND_CLI) --help | head -1)"
tailwind-dev: tailwind-dev:
@echo "Building Tailwind CSS (development - all classes)..." @echo "Building Tailwind CSS (development)..."
npm run tailwind:admin $(TAILWIND_CLI) -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css
npm run tailwind:vendor $(TAILWIND_CLI) -i static/vendor/css/tailwind.css -o static/vendor/css/tailwind.output.css
@echo "Tailwind CSS built (admin + vendor)" $(TAILWIND_CLI) -i static/shop/css/tailwind.css -o static/shop/css/tailwind.output.css
$(TAILWIND_CLI) -i static/platform/css/tailwind.css -o static/platform/css/tailwind.output.css
@echo "Tailwind CSS built (admin + vendor + shop + platform)"
tailwind-build: tailwind-build:
@echo "Building Tailwind CSS (production - purged)..." @echo "Building Tailwind CSS (production - minified)..."
npm run build $(TAILWIND_CLI) -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css --minify
@echo "Tailwind CSS built for production" $(TAILWIND_CLI) -i static/vendor/css/tailwind.css -o static/vendor/css/tailwind.output.css --minify
$(TAILWIND_CLI) -i static/shop/css/tailwind.css -o static/shop/css/tailwind.output.css --minify
$(TAILWIND_CLI) -i static/platform/css/tailwind.css -o static/platform/css/tailwind.output.css --minify
@echo "Tailwind CSS built and minified for production"
tailwind-watch: tailwind-watch:
@echo "Watching Tailwind CSS for changes..." @echo "Watching Tailwind CSS for changes..."
@echo "Note: This watches admin CSS only. Run in separate terminal." @echo "Note: This watches admin CSS only. Run in separate terminal."
npx tailwindcss build static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css --watch $(TAILWIND_CLI) -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css --watch
# ============================================================================= # =============================================================================
# DOCKER # DOCKER
@@ -425,10 +436,10 @@ help:
@echo " docs-serve - Start documentation server" @echo " docs-serve - Start documentation server"
@echo " docs-build - Build documentation" @echo " docs-build - Build documentation"
@echo "" @echo ""
@echo "=== FRONTEND / TAILWIND ===" @echo "=== FRONTEND / TAILWIND (No Node.js Required) ==="
@echo " npm-install - Install npm dependencies" @echo " tailwind-install - Install Tailwind standalone CLI"
@echo " tailwind-dev - Build Tailwind CSS (development)" @echo " tailwind-dev - Build Tailwind CSS (development)"
@echo " tailwind-build - Build Tailwind CSS (production)" @echo " tailwind-build - Build Tailwind CSS (production, minified)"
@echo " tailwind-watch - Watch and rebuild on changes" @echo " tailwind-watch - Watch and rebuild on changes"
@echo "" @echo ""
@echo "=== DOCKER ===" @echo "=== DOCKER ==="

View File

@@ -42,10 +42,14 @@ from app.core.database import get_db
from app.exceptions import ( from app.exceptions import (
AdminRequiredException, AdminRequiredException,
InsufficientPermissionsException, InsufficientPermissionsException,
InsufficientVendorPermissionsException,
InvalidTokenException, InvalidTokenException,
UnauthorizedVendorAccessException, UnauthorizedVendorAccessException,
VendorAccessDeniedException,
VendorNotFoundException, VendorNotFoundException,
VendorOwnerOnlyException,
) )
from app.services.vendor_service import vendor_service
from middleware.auth import AuthManager from middleware.auth import AuthManager
from middleware.rate_limiter import RateLimiter from middleware.rate_limiter import RateLimiter
from models.database.user import User from models.database.user import User
@@ -545,12 +549,16 @@ def require_vendor_permission(permission: str):
""" """
Dependency factory to require a specific vendor permission. Dependency factory to require a specific vendor permission.
Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The vendor object is loaded and stored in request.state.vendor for endpoint use.
Usage: Usage:
@router.get("/products") @router.get("/products")
def list_products( def list_products(
vendor: Vendor = Depends(get_vendor_from_code), request: Request,
user: User = Depends(require_vendor_permission(VendorPermissions.PRODUCTS_VIEW.value)) user: User = Depends(require_vendor_permission(VendorPermissions.PRODUCTS_VIEW.value))
): ):
vendor = request.state.vendor # Vendor is set by this dependency
... ...
""" """
@@ -559,10 +567,17 @@ def require_vendor_permission(permission: str):
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_from_cookie_or_header), current_user: User = Depends(get_current_vendor_from_cookie_or_header),
) -> User: ) -> User:
# Get vendor from request state (set by middleware) # Get vendor ID from JWT token
vendor = getattr(request.state, "vendor", None) if not hasattr(current_user, "token_vendor_id"):
if not vendor: raise InvalidTokenException("Token missing vendor information. Please login again.")
raise VendorAccessDeniedException("No vendor context")
vendor_id = current_user.token_vendor_id
# Load vendor from database (raises VendorNotFoundException if not found)
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
# Store vendor in request state for endpoint use
request.state.vendor = vendor
# Check if user has permission # Check if user has permission
if not current_user.has_vendor_permission(vendor.id, permission): if not current_user.has_vendor_permission(vendor.id, permission):
@@ -584,16 +599,29 @@ def require_vendor_owner(
""" """
Dependency to require vendor owner role. Dependency to require vendor owner role.
Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The vendor object is loaded and stored in request.state.vendor for endpoint use.
Usage: Usage:
@router.delete("/team/{user_id}") @router.delete("/team/{user_id}")
def remove_team_member( def remove_team_member(
request: Request,
user: User = Depends(require_vendor_owner) user: User = Depends(require_vendor_owner)
): ):
vendor = request.state.vendor # Vendor is set by this dependency
... ...
""" """
vendor = getattr(request.state, "vendor", None) # Get vendor ID from JWT token
if not vendor: if not hasattr(current_user, "token_vendor_id"):
raise VendorAccessDeniedException("No vendor context") raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
# Load vendor from database (raises VendorNotFoundException if not found)
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
# Store vendor in request state for endpoint use
request.state.vendor = vendor
if not current_user.is_owner_of(vendor.id): if not current_user.is_owner_of(vendor.id):
raise VendorOwnerOnlyException( raise VendorOwnerOnlyException(
@@ -608,14 +636,19 @@ def require_any_vendor_permission(*permissions: str):
""" """
Dependency factory to require ANY of the specified permissions. Dependency factory to require ANY of the specified permissions.
Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The vendor object is loaded and stored in request.state.vendor for endpoint use.
Usage: Usage:
@router.get("/dashboard") @router.get("/dashboard")
def dashboard( def dashboard(
request: Request,
user: User = Depends(require_any_vendor_permission( user: User = Depends(require_any_vendor_permission(
VendorPermissions.DASHBOARD_VIEW.value, VendorPermissions.DASHBOARD_VIEW.value,
VendorPermissions.REPORTS_VIEW.value VendorPermissions.REPORTS_VIEW.value
)) ))
): ):
vendor = request.state.vendor # Vendor is set by this dependency
... ...
""" """
@@ -624,9 +657,17 @@ def require_any_vendor_permission(*permissions: str):
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_from_cookie_or_header), current_user: User = Depends(get_current_vendor_from_cookie_or_header),
) -> User: ) -> User:
vendor = getattr(request.state, "vendor", None) # Get vendor ID from JWT token
if not vendor: if not hasattr(current_user, "token_vendor_id"):
raise VendorAccessDeniedException("No vendor context") raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
# Load vendor from database (raises VendorNotFoundException if not found)
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
# Store vendor in request state for endpoint use
request.state.vendor = vendor
# Check if user has ANY of the required permissions # Check if user has ANY of the required permissions
has_permission = any( has_permission = any(
@@ -648,14 +689,19 @@ def require_all_vendor_permissions(*permissions: str):
""" """
Dependency factory to require ALL of the specified permissions. Dependency factory to require ALL of the specified permissions.
Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The vendor object is loaded and stored in request.state.vendor for endpoint use.
Usage: Usage:
@router.post("/products/bulk-delete") @router.post("/products/bulk-delete")
def bulk_delete_products( def bulk_delete_products(
request: Request,
user: User = Depends(require_all_vendor_permissions( user: User = Depends(require_all_vendor_permissions(
VendorPermissions.PRODUCTS_VIEW.value, VendorPermissions.PRODUCTS_VIEW.value,
VendorPermissions.PRODUCTS_DELETE.value VendorPermissions.PRODUCTS_DELETE.value
)) ))
): ):
vendor = request.state.vendor # Vendor is set by this dependency
... ...
""" """
@@ -664,9 +710,17 @@ def require_all_vendor_permissions(*permissions: str):
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_from_cookie_or_header), current_user: User = Depends(get_current_vendor_from_cookie_or_header),
) -> User: ) -> User:
vendor = getattr(request.state, "vendor", None) # Get vendor ID from JWT token
if not vendor: if not hasattr(current_user, "token_vendor_id"):
raise VendorAccessDeniedException("No vendor context") raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor_id = current_user.token_vendor_id
# Load vendor from database (raises VendorNotFoundException if not found)
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
# Store vendor in request state for endpoint use
request.state.vendor = vendor
# Check if user has ALL required permissions # Check if user has ALL required permissions
missing_permissions = [ missing_permissions = [
@@ -688,17 +742,29 @@ def require_all_vendor_permissions(*permissions: str):
def get_user_permissions( def get_user_permissions(
request: Request, request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_vendor_from_cookie_or_header), current_user: User = Depends(get_current_vendor_from_cookie_or_header),
) -> list: ) -> list:
""" """
Get all permissions for current user in current vendor. Get all permissions for current user in current vendor.
Returns empty list if no vendor context. Uses token_vendor_id from JWT token (authenticated vendor API pattern).
Also sets request.state.vendor for endpoint use.
Returns empty list if no vendor context in token.
""" """
vendor = getattr(request.state, "vendor", None) # Get vendor ID from JWT token
if not vendor: if not hasattr(current_user, "token_vendor_id"):
return [] return []
vendor_id = current_user.token_vendor_id
# Load vendor from database
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
# Store vendor in request state for endpoint use
request.state.vendor = vendor
# If owner, return all permissions # If owner, return all permissions
if current_user.is_owner_of(vendor.id): if current_user.is_owner_of(vendor.id):
from app.core.permissions import VendorPermissions from app.core.permissions import VendorPermissions

View File

@@ -20,7 +20,7 @@ from app.core.environment import should_use_secure_cookies
from app.exceptions import InvalidCredentialsException from app.exceptions import InvalidCredentialsException
from app.services.auth_service import auth_service from app.services.auth_service import auth_service
from models.database.user import User from models.database.user import User
from models.schema.auth import LoginResponse, UserLogin, UserResponse from models.schema.auth import LoginResponse, LogoutResponse, UserLogin, UserResponse
router = APIRouter(prefix="/auth") router = APIRouter(prefix="/auth")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -97,7 +97,7 @@ def get_current_admin(current_user: User = Depends(get_current_admin_api)):
return current_user return current_user
@router.post("/logout") @router.post("/logout", response_model=LogoutResponse)
def admin_logout(response: Response): def admin_logout(response: Response):
""" """
Admin logout endpoint. Admin logout endpoint.
@@ -115,4 +115,4 @@ def admin_logout(response: Response):
logger.debug("Deleted admin_token cookie") logger.debug("Deleted admin_token cookie")
return {"message": "Logged out successfully"} return LogoutResponse(message="Logged out successfully")

View File

@@ -5,12 +5,13 @@ RESTful API for architecture validation and violation management
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api from app.api.deps import get_current_admin_api
from app.core.database import get_db from app.core.database import get_db
from app.exceptions import ViolationNotFoundException
from app.services.code_quality_service import code_quality_service from app.services.code_quality_service import code_quality_service
from models.database.user import User from models.database.user import User
@@ -136,25 +137,23 @@ async def trigger_scan(
Trigger a new architecture scan Trigger a new architecture scan
Requires authentication. Runs the validator script and stores results. Requires authentication. Runs the validator script and stores results.
Domain exceptions (ScanTimeoutException, ScanParseException) bubble up to global handler.
""" """
try: scan = code_quality_service.run_scan(
scan = code_quality_service.run_scan( db, triggered_by=f"manual:{current_user.username}"
db, triggered_by=f"manual:{current_user.username}" )
)
return ScanResponse( return ScanResponse(
id=scan.id, id=scan.id,
timestamp=scan.timestamp.isoformat(), timestamp=scan.timestamp.isoformat(),
total_files=scan.total_files, total_files=scan.total_files,
total_violations=scan.total_violations, total_violations=scan.total_violations,
errors=scan.errors, errors=scan.errors,
warnings=scan.warnings, warnings=scan.warnings,
duration_seconds=scan.duration_seconds, duration_seconds=scan.duration_seconds,
triggered_by=scan.triggered_by, triggered_by=scan.triggered_by,
git_commit_hash=scan.git_commit_hash, git_commit_hash=scan.git_commit_hash,
) )
except Exception as e:
raise HTTPException(status_code=500, detail=f"Scan failed: {str(e)}")
@router.get("/scans", response_model=list[ScanResponse]) @router.get("/scans", response_model=list[ScanResponse])
@@ -269,7 +268,7 @@ async def get_violation(
violation = code_quality_service.get_violation_by_id(db, violation_id) violation = code_quality_service.get_violation_by_id(db, violation_id)
if not violation: if not violation:
raise HTTPException(status_code=404, detail="Violation not found") raise ViolationNotFoundException(violation_id)
# Format assignments # Format assignments
assignments = [ assignments = [
@@ -331,29 +330,26 @@ async def assign_violation(
Updates violation status to 'assigned'. Updates violation status to 'assigned'.
""" """
try: assignment = code_quality_service.assign_violation(
assignment = code_quality_service.assign_violation( db,
db, violation_id=violation_id,
violation_id=violation_id, user_id=request.user_id,
user_id=request.user_id, assigned_by=current_user.id,
assigned_by=current_user.id, due_date=request.due_date,
due_date=request.due_date, priority=request.priority,
priority=request.priority, )
)
return { return {
"id": assignment.id, "id": assignment.id,
"violation_id": assignment.violation_id, "violation_id": assignment.violation_id,
"user_id": assignment.user_id, "user_id": assignment.user_id,
"assigned_at": assignment.assigned_at.isoformat(), "assigned_at": assignment.assigned_at.isoformat(),
"assigned_by": assignment.assigned_by, "assigned_by": assignment.assigned_by,
"due_date": ( "due_date": (
assignment.due_date.isoformat() if assignment.due_date else None assignment.due_date.isoformat() if assignment.due_date else None
), ),
"priority": assignment.priority, "priority": assignment.priority,
} }
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/violations/{violation_id}/resolve") @router.post("/violations/{violation_id}/resolve")
@@ -367,28 +363,24 @@ async def resolve_violation(
Mark violation as resolved Mark violation as resolved
Records resolution timestamp and user. Records resolution timestamp and user.
ViolationNotFoundException bubbles up if violation doesn't exist.
""" """
try: violation = code_quality_service.resolve_violation(
violation = code_quality_service.resolve_violation( db,
db, violation_id=violation_id,
violation_id=violation_id, resolved_by=current_user.id,
resolved_by=current_user.id, resolution_note=request.resolution_note,
resolution_note=request.resolution_note, )
)
return { return {
"id": violation.id, "id": violation.id,
"status": violation.status, "status": violation.status,
"resolved_at": ( "resolved_at": (
violation.resolved_at.isoformat() if violation.resolved_at else None violation.resolved_at.isoformat() if violation.resolved_at else None
), ),
"resolved_by": violation.resolved_by, "resolved_by": violation.resolved_by,
"resolution_note": violation.resolution_note, "resolution_note": violation.resolution_note,
} }
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/violations/{violation_id}/ignore") @router.post("/violations/{violation_id}/ignore")
@@ -402,28 +394,24 @@ async def ignore_violation(
Mark violation as ignored (won't fix) Mark violation as ignored (won't fix)
Records reason for ignoring. Records reason for ignoring.
ViolationNotFoundException bubbles up if violation doesn't exist.
""" """
try: violation = code_quality_service.ignore_violation(
violation = code_quality_service.ignore_violation( db,
db, violation_id=violation_id,
violation_id=violation_id, ignored_by=current_user.id,
ignored_by=current_user.id, reason=request.reason,
reason=request.reason, )
)
return { return {
"id": violation.id, "id": violation.id,
"status": violation.status, "status": violation.status,
"resolved_at": ( "resolved_at": (
violation.resolved_at.isoformat() if violation.resolved_at else None violation.resolved_at.isoformat() if violation.resolved_at else None
), ),
"resolved_by": violation.resolved_by, "resolved_by": violation.resolved_by,
"resolution_note": violation.resolution_note, "resolution_note": violation.resolution_note,
} }
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/violations/{violation_id}/comments") @router.post("/violations/{violation_id}/comments")
@@ -438,23 +426,20 @@ async def add_comment(
For team collaboration and discussion. For team collaboration and discussion.
""" """
try: comment = code_quality_service.add_comment(
comment = code_quality_service.add_comment( db,
db, violation_id=violation_id,
violation_id=violation_id, user_id=current_user.id,
user_id=current_user.id, comment=request.comment,
comment=request.comment, )
)
return { return {
"id": comment.id, "id": comment.id,
"violation_id": comment.violation_id, "violation_id": comment.violation_id,
"user_id": comment.user_id, "user_id": comment.user_id,
"comment": comment.comment, "comment": comment.comment,
"created_at": comment.created_at.isoformat(), "created_at": comment.created_at.isoformat(),
} }
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/stats", response_model=DashboardStatsResponse) @router.get("/stats", response_model=DashboardStatsResponse)

View File

@@ -10,7 +10,7 @@ Platform administrators can:
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -170,28 +170,9 @@ def list_all_pages(
Filter by vendor_id to see specific vendor pages. Filter by vendor_id to see specific vendor pages.
""" """
if vendor_id: pages = content_page_service.list_all_pages(
pages = content_page_service.list_all_vendor_pages( db, vendor_id=vendor_id, include_unpublished=include_unpublished
db, vendor_id=vendor_id, include_unpublished=include_unpublished )
)
else:
# Get all pages (both platform and vendor)
from sqlalchemy import and_
from models.database.content_page import ContentPage
filters = []
if not include_unpublished:
filters.append(ContentPage.is_published == True)
pages = (
db.query(ContentPage)
.filter(and_(*filters) if filters else True)
.order_by(
ContentPage.vendor_id, ContentPage.display_order, ContentPage.title
)
.all()
)
return [page.to_dict() for page in pages] return [page.to_dict() for page in pages]
@@ -203,11 +184,7 @@ def get_page(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Get a specific content page by ID.""" """Get a specific content page by ID."""
page = content_page_service.get_page_by_id(db, page_id) page = content_page_service.get_page_by_id_or_raise(db, page_id)
if not page:
raise HTTPException(status_code=404, detail="Content page not found")
return page.to_dict() return page.to_dict()
@@ -219,7 +196,7 @@ def update_page(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Update a content page (platform or vendor).""" """Update a content page (platform or vendor)."""
page = content_page_service.update_page( page = content_page_service.update_page_or_raise(
db, db,
page_id=page_id, page_id=page_id,
title=page_data.title, title=page_data.title,
@@ -234,10 +211,6 @@ def update_page(
display_order=page_data.display_order, display_order=page_data.display_order,
updated_by=current_user.id, updated_by=current_user.id,
) )
if not page:
raise HTTPException(status_code=404, detail="Content page not found")
return page.to_dict() return page.to_dict()
@@ -248,9 +221,4 @@ def delete_page(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Delete a content page.""" """Delete a content page."""
success = content_page_service.delete_page(db, page_id) content_page_service.delete_page_or_raise(db, page_id)
if not success:
raise HTTPException(status_code=404, detail="Content page not found")
return

View File

@@ -18,6 +18,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api from app.api.deps import get_current_admin_api
from app.core.database import get_db from app.core.database import get_db
from app.core.logging import reload_log_level from app.core.logging import reload_log_level
from app.exceptions import ConfirmationRequiredException, ResourceNotFoundException
from app.services.admin_audit_service import admin_audit_service from app.services.admin_audit_service import admin_audit_service
from app.services.admin_settings_service import admin_settings_service from app.services.admin_settings_service import admin_settings_service
from app.services.log_service import log_service from app.services.log_service import log_service
@@ -26,8 +27,12 @@ from models.schema.admin import (
ApplicationLogFilters, ApplicationLogFilters,
ApplicationLogListResponse, ApplicationLogListResponse,
FileLogResponse, FileLogResponse,
LogCleanupResponse,
LogDeleteResponse,
LogFileListResponse,
LogSettingsResponse, LogSettingsResponse,
LogSettingsUpdate, LogSettingsUpdate,
LogSettingsUpdateResponse,
LogStatistics, LogStatistics,
) )
@@ -87,7 +92,7 @@ def get_log_statistics(
return log_service.get_log_statistics(db, days) return log_service.get_log_statistics(db, days)
@router.delete("/database/cleanup") @router.delete("/database/cleanup", response_model=LogCleanupResponse)
def cleanup_old_logs( def cleanup_old_logs(
retention_days: int = Query(30, ge=1, le=365), retention_days: int = Query(30, ge=1, le=365),
confirm: bool = Query(False, description="Must be true to confirm cleanup"), confirm: bool = Query(False, description="Must be true to confirm cleanup"),
@@ -99,13 +104,8 @@ def cleanup_old_logs(
Requires confirmation parameter. Requires confirmation parameter.
""" """
from fastapi import HTTPException
if not confirm: if not confirm:
raise HTTPException( raise ConfirmationRequiredException(operation="cleanup_logs")
status_code=400,
detail="Cleanup requires confirmation parameter: confirm=true",
)
deleted_count = log_service.cleanup_old_logs(db, retention_days) deleted_count = log_service.cleanup_old_logs(db, retention_days)
@@ -119,13 +119,13 @@ def cleanup_old_logs(
details={"retention_days": retention_days, "deleted_count": deleted_count}, details={"retention_days": retention_days, "deleted_count": deleted_count},
) )
return { return LogCleanupResponse(
"message": f"Deleted {deleted_count} log entries older than {retention_days} days", message=f"Deleted {deleted_count} log entries older than {retention_days} days",
"deleted_count": deleted_count, deleted_count=deleted_count,
} )
@router.delete("/database/{log_id}") @router.delete("/database/{log_id}", response_model=LogDeleteResponse)
def delete_log( def delete_log(
log_id: int, log_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -144,7 +144,7 @@ def delete_log(
details={}, details={},
) )
return {"message": message} return LogDeleteResponse(message=message)
# ============================================================================ # ============================================================================
@@ -152,7 +152,7 @@ def delete_log(
# ============================================================================ # ============================================================================
@router.get("/files") @router.get("/files", response_model=LogFileListResponse)
def list_log_files( def list_log_files(
current_admin: User = Depends(get_current_admin_api), current_admin: User = Depends(get_current_admin_api),
): ):
@@ -161,7 +161,7 @@ def list_log_files(
Returns list of log files with size and modification date. Returns list of log files with size and modification date.
""" """
return {"files": log_service.list_log_files()} return LogFileListResponse(files=log_service.list_log_files())
@router.get("/files/{filename}", response_model=FileLogResponse) @router.get("/files/{filename}", response_model=FileLogResponse)
@@ -191,7 +191,6 @@ def download_log_file(
from pathlib import Path from pathlib import Path
from app.core.config import settings from app.core.config import settings
from fastapi import HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
# Determine log file path # Determine log file path
@@ -202,7 +201,7 @@ def download_log_file(
log_file = Path("logs") / filename log_file = Path("logs") / filename
if not log_file.exists(): if not log_file.exists():
raise HTTPException(status_code=404, detail=f"Log file '{filename}' not found") raise ResourceNotFoundException(resource_type="LogFile", identifier=filename)
# Log action # Log action
from app.core.database import get_db from app.core.database import get_db
@@ -267,7 +266,7 @@ def get_log_settings(
) )
@router.put("/settings") @router.put("/settings", response_model=LogSettingsUpdateResponse)
def update_log_settings( def update_log_settings(
settings_update: LogSettingsUpdate, settings_update: LogSettingsUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -335,8 +334,8 @@ def update_log_settings(
details={"updated_fields": updated}, details={"updated_fields": updated},
) )
return { return LogSettingsUpdateResponse(
"message": "Log settings updated successfully", message="Log settings updated successfully",
"updated_fields": updated, updated_fields=updated,
"note": "Log level changes are applied immediately. File rotation settings require restart.", note="Log level changes are applied immediately. File rotation settings require restart.",
} )

View File

@@ -15,6 +15,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api from app.api.deps import get_current_admin_api
from app.core.database import get_db from app.core.database import get_db
from app.exceptions import ConfirmationRequiredException, ResourceNotFoundException
from app.services.admin_audit_service import admin_audit_service from app.services.admin_audit_service import admin_audit_service
from app.services.admin_settings_service import admin_settings_service from app.services.admin_settings_service import admin_settings_service
from models.database.user import User from models.database.user import User
@@ -78,9 +79,9 @@ def get_setting(
setting = admin_settings_service.get_setting_by_key(db, key) setting = admin_settings_service.get_setting_by_key(db, key)
if not setting: if not setting:
from fastapi import HTTPException raise ResourceNotFoundException(
resource_type="Setting", identifier=key
raise HTTPException(status_code=404, detail=f"Setting '{key}' not found") )
return AdminSettingResponse.model_validate(setting) return AdminSettingResponse.model_validate(setting)
@@ -184,12 +185,10 @@ def delete_setting(
Requires confirmation parameter. Requires confirmation parameter.
WARNING: Deleting settings may affect platform functionality. WARNING: Deleting settings may affect platform functionality.
""" """
from fastapi import HTTPException
if not confirm: if not confirm:
raise HTTPException( raise ConfirmationRequiredException(
status_code=400, operation="delete_setting",
detail="Deletion requires confirmation parameter: confirm=true", message="Deletion requires confirmation parameter: confirm=true",
) )
message = admin_settings_service.delete_setting( message = admin_settings_service.delete_setting(

View File

@@ -1,25 +1,30 @@
# app/api/v1/admin/users.py # app/api/v1/admin/users.py
""" """
User management endpoints for admin. User management endpoints for admin.
All endpoints use the admin_service for business logic.
Domain exceptions are raised by the service and converted to HTTP responses
by the global exception handler.
""" """
import logging import logging
import math
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query from fastapi import APIRouter, Body, Depends, Path, Query
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api from app.api.deps import get_current_admin_api
from app.core.database import get_db from app.core.database import get_db
from app.services.admin_service import admin_service from app.services.admin_service import admin_service
from middleware.auth import AuthManager
from app.services.stats_service import stats_service from app.services.stats_service import stats_service
from models.database.user import User from models.database.user import User
from models.schema.auth import ( from models.schema.auth import (
UserCreate, UserCreate,
UserDeleteResponse,
UserDetailResponse, UserDetailResponse,
UserListResponse, UserListResponse,
UserResponse, UserResponse,
UserSearchResponse,
UserStatusToggleResponse,
UserUpdate, UserUpdate,
) )
@@ -38,31 +43,19 @@ def get_all_users(
current_admin: User = Depends(get_current_admin_api), current_admin: User = Depends(get_current_admin_api),
): ):
"""Get paginated list of all users (Admin only).""" """Get paginated list of all users (Admin only)."""
query = db.query(User) # Convert string params to proper types
is_active_bool = None
# Apply filters
if search:
search_term = f"%{search.lower()}%"
query = query.filter(
(User.username.ilike(search_term))
| (User.email.ilike(search_term))
| (User.first_name.ilike(search_term))
| (User.last_name.ilike(search_term))
)
if role:
query = query.filter(User.role == role)
if is_active: if is_active:
query = query.filter(User.is_active == (is_active.lower() == "true")) is_active_bool = is_active.lower() == "true"
# Get total count users, total, pages = admin_service.list_users(
total = query.count() db=db,
pages = math.ceil(total / per_page) if total > 0 else 1 page=page,
per_page=per_page,
# Apply pagination search=search if search else None,
skip = (page - 1) * per_page role=role if role else None,
users = query.order_by(User.created_at.desc()).offset(skip).limit(per_page).all() is_active=is_active_bool,
)
return UserListResponse( return UserListResponse(
items=[UserResponse.model_validate(user) for user in users], items=[UserResponse.model_validate(user) for user in users],
@@ -80,30 +73,16 @@ def create_user(
current_admin: User = Depends(get_current_admin_api), current_admin: User = Depends(get_current_admin_api),
): ):
"""Create a new user (Admin only).""" """Create a new user (Admin only)."""
# Check if email exists user = admin_service.create_user(
if db.query(User).filter(User.email == user_data.email).first(): db=db,
raise HTTPException(status_code=400, detail="Email already registered")
# Check if username exists
if db.query(User).filter(User.username == user_data.username).first():
raise HTTPException(status_code=400, detail="Username already taken")
# Create user
auth_manager = AuthManager()
user = User(
email=user_data.email, email=user_data.email,
username=user_data.username, username=user_data.username,
hashed_password=auth_manager.hash_password(user_data.password), password=user_data.password,
first_name=user_data.first_name, first_name=user_data.first_name,
last_name=user_data.last_name, last_name=user_data.last_name,
role=user_data.role, role=user_data.role,
is_active=True, current_admin_id=current_admin.id,
) )
db.add(user)
db.commit()
db.refresh(user)
logger.info(f"Admin {current_admin.username} created user {user.username}")
return UserDetailResponse( return UserDetailResponse(
id=user.id, id=user.id,
@@ -118,8 +97,8 @@ def create_user(
last_name=user.last_name, last_name=user.last_name,
full_name=user.full_name, full_name=user.full_name,
is_email_verified=user.is_email_verified, is_email_verified=user.is_email_verified,
owned_companies_count=len(user.owned_companies), owned_companies_count=len(user.owned_companies) if user.owned_companies else 0,
vendor_memberships_count=len(user.vendor_memberships), vendor_memberships_count=len(user.vendor_memberships) if user.vendor_memberships else 0,
) )
@@ -132,7 +111,7 @@ def get_user_statistics(
return stats_service.get_user_statistics(db) return stats_service.get_user_statistics(db)
@router.get("/search") @router.get("/search", response_model=UserSearchResponse)
def search_users( def search_users(
q: str = Query(..., min_length=2, description="Search query (username or email)"), q: str = Query(..., min_length=2, description="Search query (username or email)"),
limit: int = Query(10, ge=1, le=50), limit: int = Query(10, ge=1, le=50),
@@ -144,25 +123,8 @@ def search_users(
Used for autocomplete in ownership transfer. Used for autocomplete in ownership transfer.
""" """
search_term = f"%{q.lower()}%" users = admin_service.search_users(db=db, query=q, limit=limit)
users = ( return UserSearchResponse(users=users)
db.query(User)
.filter((User.username.ilike(search_term)) | (User.email.ilike(search_term)))
.limit(limit)
.all()
)
return {
"users": [
{
"id": user.id,
"username": user.username,
"email": user.email,
"is_active": user.is_active,
}
for user in users
]
}
@router.get("/{user_id}", response_model=UserDetailResponse) @router.get("/{user_id}", response_model=UserDetailResponse)
@@ -172,15 +134,7 @@ def get_user_details(
current_admin: User = Depends(get_current_admin_api), current_admin: User = Depends(get_current_admin_api),
): ):
"""Get detailed user information (Admin only).""" """Get detailed user information (Admin only)."""
user = ( user = admin_service.get_user_details(db=db, user_id=user_id)
db.query(User)
.options(joinedload(User.owned_companies), joinedload(User.vendor_memberships))
.filter(User.id == user_id)
.first()
)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return UserDetailResponse( return UserDetailResponse(
id=user.id, id=user.id,
@@ -195,8 +149,8 @@ def get_user_details(
last_name=user.last_name, last_name=user.last_name,
full_name=user.full_name, full_name=user.full_name,
is_email_verified=user.is_email_verified, is_email_verified=user.is_email_verified,
owned_companies_count=len(user.owned_companies), owned_companies_count=len(user.owned_companies) if user.owned_companies else 0,
vendor_memberships_count=len(user.vendor_memberships), vendor_memberships_count=len(user.vendor_memberships) if user.vendor_memberships else 0,
) )
@@ -208,36 +162,19 @@ def update_user(
current_admin: User = Depends(get_current_admin_api), current_admin: User = Depends(get_current_admin_api),
): ):
"""Update user information (Admin only).""" """Update user information (Admin only)."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Prevent changing own admin status
if user.id == current_admin.id and user_update.role and user_update.role != "admin":
raise HTTPException(
status_code=400, detail="Cannot change your own admin role"
)
# Check email uniqueness if changing
if user_update.email and user_update.email != user.email:
if db.query(User).filter(User.email == user_update.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
# Check username uniqueness if changing
if user_update.username and user_update.username != user.username:
if db.query(User).filter(User.username == user_update.username).first():
raise HTTPException(status_code=400, detail="Username already taken")
# Update fields
update_data = user_update.model_dump(exclude_unset=True) update_data = user_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(user, field, value)
db.commit() user = admin_service.update_user(
db.refresh(user) db=db,
user_id=user_id,
logger.info(f"Admin {current_admin.username} updated user {user.username}") current_admin_id=current_admin.id,
email=update_data.get("email"),
username=update_data.get("username"),
first_name=update_data.get("first_name"),
last_name=update_data.get("last_name"),
role=update_data.get("role"),
is_active=update_data.get("is_active"),
)
return UserDetailResponse( return UserDetailResponse(
id=user.id, id=user.id,
@@ -252,68 +189,38 @@ def update_user(
last_name=user.last_name, last_name=user.last_name,
full_name=user.full_name, full_name=user.full_name,
is_email_verified=user.is_email_verified, is_email_verified=user.is_email_verified,
owned_companies_count=len(user.owned_companies), owned_companies_count=len(user.owned_companies) if user.owned_companies else 0,
vendor_memberships_count=len(user.vendor_memberships), vendor_memberships_count=len(user.vendor_memberships) if user.vendor_memberships else 0,
) )
@router.put("/{user_id}/status") @router.put("/{user_id}/status", response_model=UserStatusToggleResponse)
def toggle_user_status( def toggle_user_status(
user_id: int = Path(..., description="User ID"), user_id: int = Path(..., description="User ID"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api), current_admin: User = Depends(get_current_admin_api),
): ):
"""Toggle user active status (Admin only).""" """Toggle user active status (Admin only)."""
user = db.query(User).filter(User.id == user_id).first() user, message = admin_service.toggle_user_status(
db=db,
user_id=user_id,
current_admin_id=current_admin.id,
)
if not user: return UserStatusToggleResponse(message=message, is_active=user.is_active)
raise HTTPException(status_code=404, detail="User not found")
# Prevent deactivating yourself
if user.id == current_admin.id:
raise HTTPException(status_code=400, detail="Cannot deactivate yourself")
user.is_active = not user.is_active
db.commit()
action = "activated" if user.is_active else "deactivated"
logger.info(f"Admin {current_admin.username} {action} user {user.username}")
return {"message": f"User {action} successfully", "is_active": user.is_active}
@router.delete("/{user_id}") @router.delete("/{user_id}", response_model=UserDeleteResponse)
def delete_user( def delete_user(
user_id: int = Path(..., description="User ID"), user_id: int = Path(..., description="User ID"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api), current_admin: User = Depends(get_current_admin_api),
): ):
"""Delete a user (Admin only).""" """Delete a user (Admin only)."""
user = ( message = admin_service.delete_user(
db.query(User) db=db,
.options(joinedload(User.owned_companies)) user_id=user_id,
.filter(User.id == user_id) current_admin_id=current_admin.id,
.first()
) )
if not user: return UserDeleteResponse(message=message)
raise HTTPException(status_code=404, detail="User not found")
# Prevent deleting yourself
if user.id == current_admin.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
# Prevent deleting users who own companies
if user.owned_companies:
raise HTTPException(
status_code=400,
detail=f"Cannot delete user who owns {len(user.owned_companies)} company(ies). Transfer ownership first.",
)
username = user.username
db.delete(user)
db.commit()
logger.info(f"Admin {current_admin.username} deleted user {username}")
return {"message": "User deleted successfully"}

View File

@@ -16,14 +16,20 @@ This prevents:
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi import APIRouter, Depends, Request, Response
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.environment import should_use_secure_cookies from app.core.environment import should_use_secure_cookies
from app.exceptions import VendorNotFoundException
from app.services.customer_service import customer_service from app.services.customer_service import customer_service
from models.schema.auth import UserLogin from models.schema.auth import (
LogoutResponse,
PasswordResetRequestResponse,
PasswordResetResponse,
UserLogin,
)
from models.schema.customer import CustomerRegister, CustomerResponse from models.schema.customer import CustomerRegister, CustomerResponse
router = APIRouter() router = APIRouter()
@@ -62,10 +68,7 @@ def register_customer(
vendor = getattr(request.state, "vendor", None) vendor = getattr(request.state, "vendor", None)
if not vendor: if not vendor:
raise HTTPException( raise VendorNotFoundException("context", identifier_type="subdomain")
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug( logger.debug(
f"[SHOP_API] register_customer for vendor {vendor.subdomain}", f"[SHOP_API] register_customer for vendor {vendor.subdomain}",
@@ -122,10 +125,7 @@ def customer_login(
vendor = getattr(request.state, "vendor", None) vendor = getattr(request.state, "vendor", None)
if not vendor: if not vendor:
raise HTTPException( raise VendorNotFoundException("context", identifier_type="subdomain")
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug( logger.debug(
f"[SHOP_API] customer_login for vendor {vendor.subdomain}", f"[SHOP_API] customer_login for vendor {vendor.subdomain}",
@@ -199,7 +199,7 @@ def customer_login(
) )
@router.post("/auth/logout") @router.post("/auth/logout", response_model=LogoutResponse)
def customer_logout(request: Request, response: Response): def customer_logout(request: Request, response: Response):
""" """
Customer logout for current vendor. Customer logout for current vendor.
@@ -245,10 +245,10 @@ def customer_logout(request: Request, response: Response):
logger.debug(f"Deleted customer_token cookie (path={cookie_path})") logger.debug(f"Deleted customer_token cookie (path={cookie_path})")
return {"message": "Logged out successfully"} return LogoutResponse(message="Logged out successfully")
@router.post("/auth/forgot-password") @router.post("/auth/forgot-password", response_model=PasswordResetRequestResponse)
def forgot_password(request: Request, email: str, db: Session = Depends(get_db)): def forgot_password(request: Request, email: str, db: Session = Depends(get_db)):
""" """
Request password reset for customer. Request password reset for customer.
@@ -263,10 +263,7 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db))
vendor = getattr(request.state, "vendor", None) vendor = getattr(request.state, "vendor", None)
if not vendor: if not vendor:
raise HTTPException( raise VendorNotFoundException("context", identifier_type="subdomain")
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug( logger.debug(
f"[SHOP_API] forgot_password for vendor {vendor.subdomain}", f"[SHOP_API] forgot_password for vendor {vendor.subdomain}",
@@ -285,12 +282,12 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db))
logger.info(f"Password reset requested for {email} (vendor: {vendor.subdomain})") logger.info(f"Password reset requested for {email} (vendor: {vendor.subdomain})")
return { return PasswordResetRequestResponse(
"message": "If an account exists with this email, a password reset link has been sent." message="If an account exists with this email, a password reset link has been sent."
} )
@router.post("/auth/reset-password") @router.post("/auth/reset-password", response_model=PasswordResetResponse)
def reset_password( def reset_password(
request: Request, reset_token: str, new_password: str, db: Session = Depends(get_db) request: Request, reset_token: str, new_password: str, db: Session = Depends(get_db)
): ):
@@ -307,10 +304,7 @@ def reset_password(
vendor = getattr(request.state, "vendor", None) vendor = getattr(request.state, "vendor", None)
if not vendor: if not vendor:
raise HTTPException( raise VendorNotFoundException("context", identifier_type="subdomain")
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug( logger.debug(
f"[SHOP_API] reset_password for vendor {vendor.subdomain}", f"[SHOP_API] reset_password for vendor {vendor.subdomain}",
@@ -329,6 +323,6 @@ def reset_password(
logger.info(f"Password reset completed (vendor: {vendor.subdomain})") logger.info(f"Password reset completed (vendor: {vendor.subdomain})")
return { return PasswordResetResponse(
"message": "Password reset successfully. You can now log in with your new password." message="Password reset successfully. You can now log in with your new password."
} )

View File

@@ -3,17 +3,21 @@
Shop Shopping Cart API (Public) Shop Shopping Cart API (Public)
Public endpoints for managing shopping cart in shop frontend. Public endpoints for managing shopping cart in shop frontend.
Uses vendor from request.state (injected by VendorContextMiddleware). Uses vendor from middleware context (VendorContextMiddleware).
No authentication required - uses session ID for cart tracking. No authentication required - uses session ID for cart tracking.
Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain
""" """
import logging import logging
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Request from fastapi import APIRouter, Body, Depends, Path
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.services.cart_service import cart_service from app.services.cart_service import cart_service
from middleware.vendor_context import require_vendor_context
from models.database.vendor import Vendor
from models.schema.cart import ( from models.schema.cart import (
AddToCartRequest, AddToCartRequest,
CartOperationResponse, CartOperationResponse,
@@ -31,30 +35,21 @@ logger = logging.getLogger(__name__)
# ============================================================================ # ============================================================================
@router.get("/cart/{session_id}", response_model=CartResponse) @router.get("/cart/{session_id}", response_model=CartResponse) # public
def get_cart( def get_cart(
request: Request,
session_id: str = Path(..., description="Shopping session ID"), session_id: str = Path(..., description="Shopping session ID"),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> CartResponse: ) -> CartResponse:
""" """
Get shopping cart contents for current vendor. Get shopping cart contents for current vendor.
Vendor is automatically determined from request context. Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required - uses session ID for cart tracking. No authentication required - uses session ID for cart tracking.
Path Parameters: Path Parameters:
- session_id: Unique session identifier for the cart - session_id: Unique session identifier for the cart
""" """
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.info( logger.info(
f"[SHOP_API] get_cart for session {session_id}, vendor {vendor.id}", f"[SHOP_API] get_cart for session {session_id}, vendor {vendor.id}",
extra={ extra={
@@ -79,17 +74,17 @@ def get_cart(
return CartResponse.from_service_dict(cart) return CartResponse.from_service_dict(cart)
@router.post("/cart/{session_id}/items", response_model=CartOperationResponse) @router.post("/cart/{session_id}/items", response_model=CartOperationResponse) # public
def add_to_cart( def add_to_cart(
request: Request,
session_id: str = Path(..., description="Shopping session ID"), session_id: str = Path(..., description="Shopping session ID"),
cart_data: AddToCartRequest = Body(...), cart_data: AddToCartRequest = Body(...),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> CartOperationResponse: ) -> CartOperationResponse:
""" """
Add product to cart for current vendor. Add product to cart for current vendor.
Vendor is automatically determined from request context. Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required - uses session ID. No authentication required - uses session ID.
Path Parameters: Path Parameters:
@@ -99,15 +94,6 @@ def add_to_cart(
- product_id: ID of product to add - product_id: ID of product to add
- quantity: Quantity to add (default: 1) - quantity: Quantity to add (default: 1)
""" """
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.info( logger.info(
f"[SHOP_API] add_to_cart: product {cart_data.product_id}, qty {cart_data.quantity}, session {session_id}", f"[SHOP_API] add_to_cart: product {cart_data.product_id}, qty {cart_data.quantity}, session {session_id}",
extra={ extra={
@@ -140,18 +126,18 @@ def add_to_cart(
@router.put( @router.put(
"/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse "/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse
) ) # public
def update_cart_item( def update_cart_item(
request: Request,
session_id: str = Path(..., description="Shopping session ID"), session_id: str = Path(..., description="Shopping session ID"),
product_id: int = Path(..., description="Product ID", gt=0), product_id: int = Path(..., description="Product ID", gt=0),
cart_data: UpdateCartItemRequest = Body(...), cart_data: UpdateCartItemRequest = Body(...),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> CartOperationResponse: ) -> CartOperationResponse:
""" """
Update cart item quantity for current vendor. Update cart item quantity for current vendor.
Vendor is automatically determined from request context. Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required - uses session ID. No authentication required - uses session ID.
Path Parameters: Path Parameters:
@@ -161,15 +147,6 @@ def update_cart_item(
Request Body: Request Body:
- quantity: New quantity (must be >= 1) - quantity: New quantity (must be >= 1)
""" """
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug( logger.debug(
f"[SHOP_API] update_cart_item: product {product_id}, qty {cart_data.quantity}", f"[SHOP_API] update_cart_item: product {product_id}, qty {cart_data.quantity}",
extra={ extra={
@@ -194,32 +171,23 @@ def update_cart_item(
@router.delete( @router.delete(
"/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse "/cart/{session_id}/items/{product_id}", response_model=CartOperationResponse
) ) # public
def remove_from_cart( def remove_from_cart(
request: Request,
session_id: str = Path(..., description="Shopping session ID"), session_id: str = Path(..., description="Shopping session ID"),
product_id: int = Path(..., description="Product ID", gt=0), product_id: int = Path(..., description="Product ID", gt=0),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> CartOperationResponse: ) -> CartOperationResponse:
""" """
Remove item from cart for current vendor. Remove item from cart for current vendor.
Vendor is automatically determined from request context. Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required - uses session ID. No authentication required - uses session ID.
Path Parameters: Path Parameters:
- session_id: Unique session identifier for the cart - session_id: Unique session identifier for the cart
- product_id: ID of product to remove - product_id: ID of product to remove
""" """
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug( logger.debug(
f"[SHOP_API] remove_from_cart: product {product_id}", f"[SHOP_API] remove_from_cart: product {product_id}",
extra={ extra={
@@ -237,30 +205,21 @@ def remove_from_cart(
return CartOperationResponse(**result) return CartOperationResponse(**result)
@router.delete("/cart/{session_id}", response_model=ClearCartResponse) @router.delete("/cart/{session_id}", response_model=ClearCartResponse) # public
def clear_cart( def clear_cart(
request: Request,
session_id: str = Path(..., description="Shopping session ID"), session_id: str = Path(..., description="Shopping session ID"),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> ClearCartResponse: ) -> ClearCartResponse:
""" """
Clear all items from cart for current vendor. Clear all items from cart for current vendor.
Vendor is automatically determined from request context. Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required - uses session ID. No authentication required - uses session ID.
Path Parameters: Path Parameters:
- session_id: Unique session identifier for the cart - session_id: Unique session identifier for the cart
""" """
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug( logger.debug(
f"[SHOP_API] clear_cart for session {session_id}", f"[SHOP_API] clear_cart for session {session_id}",
extra={ extra={

View File

@@ -8,7 +8,7 @@ No authentication required.
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -90,16 +90,13 @@ def get_content_page(slug: str, request: Request, db: Session = Depends(get_db))
vendor = getattr(request.state, "vendor", None) vendor = getattr(request.state, "vendor", None)
vendor_id = vendor.id if vendor else None vendor_id = vendor.id if vendor else None
page = content_page_service.get_page_for_vendor( page = content_page_service.get_page_for_vendor_or_raise(
db, db,
slug=slug, slug=slug,
vendor_id=vendor_id, vendor_id=vendor_id,
include_unpublished=False, # Only show published pages include_unpublished=False, # Only show published pages
) )
if not page:
raise HTTPException(status_code=404, detail=f"Content page not found: {slug}")
return { return {
"slug": page.slug, "slug": page.slug,
"title": page.title, "title": page.title,

View File

@@ -3,17 +3,21 @@
Shop Product Catalog API (Public) Shop Product Catalog API (Public)
Public endpoints for browsing product catalog in shop frontend. Public endpoints for browsing product catalog in shop frontend.
Uses vendor from request.state (injected by VendorContextMiddleware). Uses vendor from middleware context (VendorContextMiddleware).
No authentication required. No authentication required.
Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain
""" """
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.services.product_service import product_service from app.services.product_service import product_service
from middleware.vendor_context import require_vendor_context
from models.database.vendor import Vendor
from models.schema.product import ( from models.schema.product import (
ProductDetailResponse, ProductDetailResponse,
ProductListResponse, ProductListResponse,
@@ -24,19 +28,19 @@ router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@router.get("/products", response_model=ProductListResponse) @router.get("/products", response_model=ProductListResponse) # public
def get_product_catalog( def get_product_catalog(
request: Request,
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000), limit: int = Query(100, ge=1, le=1000),
search: str | None = Query(None, description="Search products by name"), search: str | None = Query(None, description="Search products by name"),
is_featured: bool | None = Query(None, description="Filter by featured products"), is_featured: bool | None = Query(None, description="Filter by featured products"),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
Get product catalog for current vendor. Get product catalog for current vendor.
Vendor is automatically determined from request context (domain/subdomain/path). Vendor is automatically determined from request context (URL/subdomain/domain).
Only returns active products visible to customers. Only returns active products visible to customers.
No authentication required. No authentication required.
@@ -46,15 +50,6 @@ def get_product_catalog(
- search: Search query for product name/description - search: Search query for product name/description
- is_featured: Filter by featured products only - is_featured: Filter by featured products only
""" """
# Get vendor from middleware (injected by VendorContextMiddleware)
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug( logger.debug(
f"[SHOP_API] get_product_catalog for vendor: {vendor.subdomain}", f"[SHOP_API] get_product_catalog for vendor: {vendor.subdomain}",
extra={ extra={
@@ -85,30 +80,21 @@ def get_product_catalog(
) )
@router.get("/products/{product_id}", response_model=ProductDetailResponse) @router.get("/products/{product_id}", response_model=ProductDetailResponse) # public
def get_product_details( def get_product_details(
request: Request,
product_id: int = Path(..., description="Product ID", gt=0), product_id: int = Path(..., description="Product ID", gt=0),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
Get detailed product information for customers. Get detailed product information for customers.
Vendor is automatically determined from request context. Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required. No authentication required.
Path Parameters: Path Parameters:
- product_id: ID of the product to retrieve - product_id: ID of the product to retrieve
""" """
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug( logger.debug(
f"[SHOP_API] get_product_details for product {product_id}", f"[SHOP_API] get_product_details for product {product_id}",
extra={ extra={
@@ -131,19 +117,19 @@ def get_product_details(
return ProductDetailResponse.model_validate(product) return ProductDetailResponse.model_validate(product)
@router.get("/products/search", response_model=ProductListResponse) @router.get("/products/search", response_model=ProductListResponse) # public
def search_products( def search_products(
request: Request,
q: str = Query(..., min_length=1, description="Search query"), q: str = Query(..., min_length=1, description="Search query"),
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100), limit: int = Query(50, ge=1, le=100),
vendor: Vendor = Depends(require_vendor_context()),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
Search products in current vendor's catalog. Search products in current vendor's catalog.
Searches in product names, descriptions, and SKUs. Searches in product names, descriptions, and SKUs.
Vendor is automatically determined from request context. Vendor is automatically determined from request context (URL/subdomain/domain).
No authentication required. No authentication required.
Query Parameters: Query Parameters:
@@ -151,15 +137,6 @@ def search_products(
- skip: Number of results to skip (pagination) - skip: Number of results to skip (pagination)
- limit: Maximum number of results to return - limit: Maximum number of results to return
""" """
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise HTTPException(
status_code=404,
detail="Vendor not found. Please access via vendor domain/subdomain/path.",
)
logger.debug( logger.debug(
f"[SHOP_API] search_products: '{q}'", f"[SHOP_API] search_products: '{q}'",
extra={ extra={

View File

@@ -1,6 +1,8 @@
# app/api/v1/vendor/analytics.py # app/api/v1/vendor/analytics.py
""" """
Vendor analytics and reporting endpoints. Vendor analytics and reporting endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
""" """
import logging import logging
@@ -10,21 +12,27 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from app.exceptions import InvalidTokenException
from app.services.stats_service import stats_service from app.services.stats_service import stats_service
from middleware.vendor_context import require_vendor_context
from models.database.user import User from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter(prefix="/analytics") router = APIRouter(prefix="/analytics")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _get_vendor_id_from_token(current_user: User) -> int:
"""Helper to get vendor_id from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return current_user.token_vendor_id
@router.get("") @router.get("")
def get_vendor_analytics( def get_vendor_analytics(
period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"), period: str = Query("30d", description="Time period: 7d, 30d, 90d, 1y"),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Get vendor analytics data for specified time period.""" """Get vendor analytics data for specified time period."""
return stats_service.get_vendor_analytics(db, vendor.id, period) vendor_id = _get_vendor_id_from_token(current_user)
return stats_service.get_vendor_analytics(db, vendor_id, period)

View File

@@ -25,8 +25,8 @@ from app.exceptions import InvalidCredentialsException
from app.services.auth_service import auth_service from app.services.auth_service import auth_service
from middleware.vendor_context import get_current_vendor from middleware.vendor_context import get_current_vendor
from models.database.user import User from models.database.user import User
from models.database.vendor import Role, Vendor, VendorUser from models.database.vendor import Vendor
from models.schema.auth import UserLogin from models.schema.auth import LogoutResponse, UserLogin, VendorUserResponse
router = APIRouter(prefix="/auth") router = APIRouter(prefix="/auth")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -68,13 +68,7 @@ def vendor_login(
if not vendor and hasattr(user_credentials, "vendor_code"): if not vendor and hasattr(user_credentials, "vendor_code"):
vendor_code = getattr(user_credentials, "vendor_code", None) vendor_code = getattr(user_credentials, "vendor_code", None)
if vendor_code: if vendor_code:
vendor = ( vendor = auth_service.get_vendor_by_code(db, vendor_code)
db.query(Vendor)
.filter(
Vendor.vendor_code == vendor_code.upper(), Vendor.is_active == True
)
.first()
)
# Authenticate user # Authenticate user
login_result = auth_service.login_user(db=db, user_credentials=user_credentials) login_result = auth_service.login_user(db=db, user_credentials=user_credentials)
@@ -91,51 +85,22 @@ def vendor_login(
vendor_role = "Member" vendor_role = "Member"
if vendor: if vendor:
# Check if user is vendor owner (via company ownership) # Check if user has access to this vendor
is_owner = vendor.company and vendor.company.owner_user_id == user.id has_access, role = auth_service.get_user_vendor_role(db, user, vendor)
if is_owner: if has_access:
vendor_role = "Owner" vendor_role = role
else: else:
# Check if user is team member logger.warning(
vendor_user = ( f"User {user.username} attempted login to vendor {vendor.vendor_code} "
db.query(VendorUser) f"but is not authorized"
.join(Role) )
.filter( raise InvalidCredentialsException(
VendorUser.user_id == user.id, "You do not have access to this vendor"
VendorUser.vendor_id == vendor.id,
VendorUser.is_active == True,
)
.first()
) )
if vendor_user:
vendor_role = vendor_user.role.name
else:
logger.warning(
f"User {user.username} attempted login to vendor {vendor.vendor_code} "
f"but is not authorized"
)
raise InvalidCredentialsException(
"You do not have access to this vendor"
)
else: else:
# No vendor context - find which vendor this user belongs to # No vendor context - find which vendor this user belongs to
# Check owned vendors first (via company ownership) vendor, vendor_role = auth_service.find_user_vendor(user)
for company in user.owned_companies:
if company.vendors:
vendor = company.vendors[0]
vendor_role = "Owner"
break
# Check vendor memberships if no owned vendor found
if not vendor and user.vendor_memberships:
active_membership = next(
(vm for vm in user.vendor_memberships if vm.is_active), None
)
if active_membership:
vendor = active_membership.vendor
vendor_role = active_membership.role.name
if not vendor: if not vendor:
raise InvalidCredentialsException("User is not associated with any vendor") raise InvalidCredentialsException("User is not associated with any vendor")
@@ -194,7 +159,7 @@ def vendor_login(
) )
@router.post("/logout") @router.post("/logout", response_model=LogoutResponse)
def vendor_logout(response: Response): def vendor_logout(response: Response):
""" """
Vendor team member logout. Vendor team member logout.
@@ -212,10 +177,10 @@ def vendor_logout(response: Response):
logger.debug("Deleted vendor_token cookie") logger.debug("Deleted vendor_token cookie")
return {"message": "Logged out successfully"} return LogoutResponse(message="Logged out successfully")
@router.get("/me") @router.get("/me", response_model=VendorUserResponse)
def get_current_vendor_user( def get_current_vendor_user(
user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db) user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db)
): ):
@@ -225,10 +190,10 @@ def get_current_vendor_user(
This endpoint can be called to verify authentication and get user info. This endpoint can be called to verify authentication and get user info.
Requires Authorization header (header-only authentication for API endpoints). Requires Authorization header (header-only authentication for API endpoints).
""" """
return { return VendorUserResponse(
"id": user.id, id=user.id,
"username": user.username, username=user.username,
"email": user.email, email=user.email,
"role": user.role, role=user.role,
"is_active": user.is_active, is_active=user.is_active,
} )

View File

@@ -10,11 +10,12 @@ Vendors can:
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, get_db from app.api.deps import get_current_vendor_api, get_db
from app.exceptions.content_page import VendorNotAssociatedException
from app.services.content_page_service import content_page_service from app.services.content_page_service import content_page_service
from models.database.user import User from models.database.user import User
@@ -106,9 +107,7 @@ def list_vendor_pages(
Returns vendor-specific overrides + platform defaults (vendor overrides take precedence). Returns vendor-specific overrides + platform defaults (vendor overrides take precedence).
""" """
if not current_user.vendor_id: if not current_user.vendor_id:
raise HTTPException( raise VendorNotAssociatedException()
status_code=403, detail="User is not associated with a vendor"
)
pages = content_page_service.list_pages_for_vendor( pages = content_page_service.list_pages_for_vendor(
db, vendor_id=current_user.vendor_id, include_unpublished=include_unpublished db, vendor_id=current_user.vendor_id, include_unpublished=include_unpublished
@@ -129,9 +128,7 @@ def list_vendor_overrides(
Shows what the vendor has customized. Shows what the vendor has customized.
""" """
if not current_user.vendor_id: if not current_user.vendor_id:
raise HTTPException( raise VendorNotAssociatedException()
status_code=403, detail="User is not associated with a vendor"
)
pages = content_page_service.list_all_vendor_pages( pages = content_page_service.list_all_vendor_pages(
db, vendor_id=current_user.vendor_id, include_unpublished=include_unpublished db, vendor_id=current_user.vendor_id, include_unpublished=include_unpublished
@@ -153,20 +150,15 @@ def get_page(
Returns vendor override if exists, otherwise platform default. Returns vendor override if exists, otherwise platform default.
""" """
if not current_user.vendor_id: if not current_user.vendor_id:
raise HTTPException( raise VendorNotAssociatedException()
status_code=403, detail="User is not associated with a vendor"
)
page = content_page_service.get_page_for_vendor( page = content_page_service.get_page_for_vendor_or_raise(
db, db,
slug=slug, slug=slug,
vendor_id=current_user.vendor_id, vendor_id=current_user.vendor_id,
include_unpublished=include_unpublished, include_unpublished=include_unpublished,
) )
if not page:
raise HTTPException(status_code=404, detail=f"Content page not found: {slug}")
return page.to_dict() return page.to_dict()
@@ -182,9 +174,7 @@ def create_vendor_page(
This will be shown instead of the platform default for this vendor. This will be shown instead of the platform default for this vendor.
""" """
if not current_user.vendor_id: if not current_user.vendor_id:
raise HTTPException( raise VendorNotAssociatedException()
status_code=403, detail="User is not associated with a vendor"
)
page = content_page_service.create_page( page = content_page_service.create_page(
db, db,
@@ -218,24 +208,13 @@ def update_vendor_page(
Can only update pages owned by this vendor. Can only update pages owned by this vendor.
""" """
if not current_user.vendor_id: if not current_user.vendor_id:
raise HTTPException( raise VendorNotAssociatedException()
status_code=403, detail="User is not associated with a vendor"
)
# Verify ownership # Update with ownership check in service layer
existing_page = content_page_service.get_page_by_id(db, page_id) page = content_page_service.update_vendor_page(
if not existing_page:
raise HTTPException(status_code=404, detail="Content page not found")
if existing_page.vendor_id != current_user.vendor_id:
raise HTTPException(
status_code=403, detail="Cannot edit pages from other vendors"
)
# Update
page = content_page_service.update_page(
db, db,
page_id=page_id, page_id=page_id,
vendor_id=current_user.vendor_id,
title=page_data.title, title=page_data.title,
content=page_data.content, content=page_data.content,
content_format=page_data.content_format, content_format=page_data.content_format,
@@ -264,21 +243,7 @@ def delete_vendor_page(
After deletion, platform default will be shown (if exists). After deletion, platform default will be shown (if exists).
""" """
if not current_user.vendor_id: if not current_user.vendor_id:
raise HTTPException( raise VendorNotAssociatedException()
status_code=403, detail="User is not associated with a vendor"
)
# Verify ownership # Delete with ownership check in service layer
existing_page = content_page_service.get_page_by_id(db, page_id) content_page_service.delete_vendor_page(db, page_id, current_user.vendor_id)
if not existing_page:
raise HTTPException(status_code=404, detail="Content page not found")
if existing_page.vendor_id != current_user.vendor_id:
raise HTTPException(
status_code=403, detail="Cannot delete pages from other vendors"
)
# Delete
content_page_service.delete_page(db, page_id)
return

View File

@@ -2,6 +2,8 @@
# app/api/v1/vendor/customers.py # app/api/v1/vendor/customers.py
""" """
Vendor customer management endpoints. Vendor customer management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
""" """
import logging import logging
@@ -11,21 +13,27 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from middleware.vendor_context import require_vendor_context from app.exceptions import InvalidTokenException
from app.services.vendor_service import vendor_service
from models.database.user import User from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter(prefix="/customers") router = APIRouter(prefix="/customers")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.get("") @router.get("")
def get_vendor_customers( def get_vendor_customers(
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000), limit: int = Query(100, ge=1, le=1000),
search: str | None = Query(None), search: str | None = Query(None),
is_active: bool | None = Query(None), is_active: bool | None = Query(None),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -38,6 +46,7 @@ def get_vendor_customers(
- Support filtering by active status - Support filtering by active status
- Return paginated results - Return paginated results
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return { return {
"customers": [], "customers": [],
"total": 0, "total": 0,
@@ -50,7 +59,6 @@ def get_vendor_customers(
@router.get("/{customer_id}") @router.get("/{customer_id}")
def get_customer_details( def get_customer_details(
customer_id: int, customer_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -63,13 +71,13 @@ def get_customer_details(
- Include order history - Include order history
- Include total spent, etc. - Include total spent, etc.
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Customer details coming in Slice 4"} return {"message": "Customer details coming in Slice 4"}
@router.get("/{customer_id}/orders") @router.get("/{customer_id}/orders")
def get_customer_orders( def get_customer_orders(
customer_id: int, customer_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -81,6 +89,7 @@ def get_customer_orders(
- Filter by vendor_id - Filter by vendor_id
- Return order details - Return order details
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"orders": [], "message": "Customer orders coming in Slice 5"} return {"orders": [], "message": "Customer orders coming in Slice 5"}
@@ -88,7 +97,6 @@ def get_customer_orders(
def update_customer( def update_customer(
customer_id: int, customer_id: int,
customer_data: dict, customer_data: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -100,13 +108,13 @@ def update_customer(
- Verify customer belongs to vendor - Verify customer belongs to vendor
- Update customer preferences - Update customer preferences
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Customer update coming in Slice 4"} return {"message": "Customer update coming in Slice 4"}
@router.put("/{customer_id}/status") @router.put("/{customer_id}/status")
def toggle_customer_status( def toggle_customer_status(
customer_id: int, customer_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -118,13 +126,13 @@ def toggle_customer_status(
- Verify customer belongs to vendor - Verify customer belongs to vendor
- Log the change - Log the change
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Customer status toggle coming in Slice 4"} return {"message": "Customer status toggle coming in Slice 4"}
@router.get("/{customer_id}/stats") @router.get("/{customer_id}/stats")
def get_customer_statistics( def get_customer_statistics(
customer_id: int, customer_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -137,6 +145,7 @@ def get_customer_statistics(
- Average order value - Average order value
- Last order date - Last order date
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return { return {
"total_orders": 0, "total_orders": 0,
"total_spent": 0.0, "total_spent": 0.0,

View File

@@ -10,7 +10,9 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from app.exceptions import InvalidTokenException, VendorNotActiveException
from app.services.stats_service import stats_service from app.services.stats_service import stats_service
from app.services.vendor_service import vendor_service
from models.database.user import User from models.database.user import User
router = APIRouter(prefix="/dashboard") router = APIRouter(prefix="/dashboard")
@@ -35,23 +37,17 @@ def get_vendor_dashboard_stats(
Vendor is determined from the JWT token (vendor_id claim). Vendor is determined from the JWT token (vendor_id claim).
Requires Authorization header (API endpoint). Requires Authorization header (API endpoint).
""" """
from fastapi import HTTPException
# Get vendor ID from token (set by get_current_vendor_api) # Get vendor ID from token (set by get_current_vendor_api)
if not hasattr(current_user, "token_vendor_id"): if not hasattr(current_user, "token_vendor_id"):
raise HTTPException( raise InvalidTokenException("Token missing vendor information. Please login again.")
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id vendor_id = current_user.token_vendor_id
# Get vendor object to include in response # Get vendor object (raises VendorNotFoundException if not found)
from models.database.vendor import Vendor vendor = vendor_service.get_vendor_by_id(db, vendor_id)
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() if not vendor.is_active:
if not vendor or not vendor.is_active: raise VendorNotActiveException(vendor.vendor_code)
raise HTTPException(status_code=404, detail="Vendor not found or inactive")
# Get vendor-scoped statistics # Get vendor-scoped statistics
stats_data = stats_service.get_vendor_stats(db=db, vendor_id=vendor_id) stats_data = stats_service.get_vendor_stats(db=db, vendor_id=vendor_id)

View File

@@ -1,4 +1,9 @@
# app/api/v1/vendor/inventory.py # app/api/v1/vendor/inventory.py
"""
Vendor inventory management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
"""
import logging import logging
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
@@ -6,10 +11,9 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from app.exceptions import InvalidTokenException
from app.services.inventory_service import inventory_service from app.services.inventory_service import inventory_service
from middleware.vendor_context import require_vendor_context
from models.database.user import User from models.database.user import User
from models.database.vendor import Vendor
from models.schema.inventory import ( from models.schema.inventory import (
InventoryAdjust, InventoryAdjust,
InventoryCreate, InventoryCreate,
@@ -24,70 +28,77 @@ router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _get_vendor_id_from_token(current_user: User) -> int:
"""Helper to get vendor_id from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return current_user.token_vendor_id
@router.post("/inventory/set", response_model=InventoryResponse) @router.post("/inventory/set", response_model=InventoryResponse)
def set_inventory( def set_inventory(
inventory: InventoryCreate, inventory: InventoryCreate,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Set exact inventory quantity (replaces existing).""" """Set exact inventory quantity (replaces existing)."""
return inventory_service.set_inventory(db, vendor.id, inventory) vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.set_inventory(db, vendor_id, inventory)
@router.post("/inventory/adjust", response_model=InventoryResponse) @router.post("/inventory/adjust", response_model=InventoryResponse)
def adjust_inventory( def adjust_inventory(
adjustment: InventoryAdjust, adjustment: InventoryAdjust,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Adjust inventory (positive to add, negative to remove).""" """Adjust inventory (positive to add, negative to remove)."""
return inventory_service.adjust_inventory(db, vendor.id, adjustment) vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.adjust_inventory(db, vendor_id, adjustment)
@router.post("/inventory/reserve", response_model=InventoryResponse) @router.post("/inventory/reserve", response_model=InventoryResponse)
def reserve_inventory( def reserve_inventory(
reservation: InventoryReserve, reservation: InventoryReserve,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Reserve inventory for an order.""" """Reserve inventory for an order."""
return inventory_service.reserve_inventory(db, vendor.id, reservation) vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.reserve_inventory(db, vendor_id, reservation)
@router.post("/inventory/release", response_model=InventoryResponse) @router.post("/inventory/release", response_model=InventoryResponse)
def release_reservation( def release_reservation(
reservation: InventoryReserve, reservation: InventoryReserve,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Release reserved inventory (cancel order).""" """Release reserved inventory (cancel order)."""
return inventory_service.release_reservation(db, vendor.id, reservation) vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.release_reservation(db, vendor_id, reservation)
@router.post("/inventory/fulfill", response_model=InventoryResponse) @router.post("/inventory/fulfill", response_model=InventoryResponse)
def fulfill_reservation( def fulfill_reservation(
reservation: InventoryReserve, reservation: InventoryReserve,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Fulfill reservation (complete order, remove from stock).""" """Fulfill reservation (complete order, remove from stock)."""
return inventory_service.fulfill_reservation(db, vendor.id, reservation) vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.fulfill_reservation(db, vendor_id, reservation)
@router.get("/inventory/product/{product_id}", response_model=ProductInventorySummary) @router.get("/inventory/product/{product_id}", response_model=ProductInventorySummary)
def get_product_inventory( def get_product_inventory(
product_id: int, product_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Get inventory summary for a product.""" """Get inventory summary for a product."""
return inventory_service.get_product_inventory(db, vendor.id, product_id) vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.get_product_inventory(db, vendor_id, product_id)
@router.get("/inventory", response_model=InventoryListResponse) @router.get("/inventory", response_model=InventoryListResponse)
@@ -96,13 +107,13 @@ def get_vendor_inventory(
limit: int = Query(100, ge=1, le=1000), limit: int = Query(100, ge=1, le=1000),
location: str | None = Query(None), location: str | None = Query(None),
low_stock: int | None = Query(None, ge=0), low_stock: int | None = Query(None, ge=0),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Get all inventory for vendor.""" """Get all inventory for vendor."""
vendor_id = _get_vendor_id_from_token(current_user)
inventories = inventory_service.get_vendor_inventory( inventories = inventory_service.get_vendor_inventory(
db, vendor.id, skip, limit, location, low_stock db, vendor_id, skip, limit, location, low_stock
) )
# Get total count # Get total count
@@ -117,23 +128,23 @@ def get_vendor_inventory(
def update_inventory( def update_inventory(
inventory_id: int, inventory_id: int,
inventory_update: InventoryUpdate, inventory_update: InventoryUpdate,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Update inventory entry.""" """Update inventory entry."""
vendor_id = _get_vendor_id_from_token(current_user)
return inventory_service.update_inventory( return inventory_service.update_inventory(
db, vendor.id, inventory_id, inventory_update db, vendor_id, inventory_id, inventory_update
) )
@router.delete("/inventory/{inventory_id}") @router.delete("/inventory/{inventory_id}")
def delete_inventory( def delete_inventory(
inventory_id: int, inventory_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Delete inventory entry.""" """Delete inventory entry."""
inventory_service.delete_inventory(db, vendor.id, inventory_id) vendor_id = _get_vendor_id_from_token(current_user)
inventory_service.delete_inventory(db, vendor_id, inventory_id)
return {"message": "Inventory deleted successfully"} return {"message": "Inventory deleted successfully"}

View File

@@ -1,7 +1,8 @@
# app/api/v1/vendor/marketplace.py # Note: Should be under /vendor/ route # app/api/v1/vendor/marketplace.py
""" """
Marketplace import endpoints for vendors. Marketplace import endpoints for vendors.
Vendor context is automatically injected by middleware.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
""" """
import logging import logging
@@ -11,37 +12,45 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from app.exceptions import InvalidTokenException, UnauthorizedVendorAccessException
from app.services.marketplace_import_job_service import marketplace_import_job_service from app.services.marketplace_import_job_service import marketplace_import_job_service
from app.services.vendor_service import vendor_service
from app.tasks.background_tasks import process_marketplace_import from app.tasks.background_tasks import process_marketplace_import
from middleware.decorators import rate_limit from middleware.decorators import rate_limit
from middleware.vendor_context import require_vendor_context # IMPORTANT
from models.database.user import User from models.database.user import User
from models.database.vendor import Vendor
from models.schema.marketplace_import_job import ( from models.schema.marketplace_import_job import (
MarketplaceImportJobRequest, MarketplaceImportJobRequest,
MarketplaceImportJobResponse, MarketplaceImportJobResponse,
) )
router = APIRouter() router = APIRouter(prefix="/marketplace")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.post("/import", response_model=MarketplaceImportJobResponse) @router.post("/import", response_model=MarketplaceImportJobResponse)
@rate_limit(max_requests=10, window_seconds=3600) @rate_limit(max_requests=10, window_seconds=3600)
async def import_products_from_marketplace( async def import_products_from_marketplace(
request: MarketplaceImportJobRequest, request: MarketplaceImportJobRequest,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
vendor: Vendor = Depends(require_vendor_context()), # ADDED: Vendor from middleware
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Import products from marketplace CSV with background processing (Protected).""" """Import products from marketplace CSV with background processing (Protected)."""
vendor = _get_vendor_from_token(current_user, db)
logger.info( logger.info(
f"Starting marketplace import: {request.marketplace} for vendor {vendor.vendor_code} " f"Starting marketplace import: {request.marketplace} for vendor {vendor.vendor_code} "
f"by user {current_user.username}" f"by user {current_user.username}"
) )
# Create import job (vendor comes from middleware) # Create import job (vendor comes from token)
import_job = marketplace_import_job_service.create_import_job( import_job = marketplace_import_job_service.create_import_job(
db, request, vendor, current_user db, request, vendor, current_user
) )
@@ -50,9 +59,9 @@ async def import_products_from_marketplace(
background_tasks.add_task( background_tasks.add_task(
process_marketplace_import, process_marketplace_import,
import_job.id, import_job.id,
request.source_url, # FIXED: was request.url request.source_url,
request.marketplace, request.marketplace,
vendor.id, # Pass vendor_id instead of vendor_code vendor.id,
request.batch_size or 1000, request.batch_size or 1000,
) )
@@ -62,7 +71,7 @@ async def import_products_from_marketplace(
marketplace=request.marketplace, marketplace=request.marketplace,
vendor_id=import_job.vendor_id, vendor_id=import_job.vendor_id,
vendor_code=vendor.vendor_code, vendor_code=vendor.vendor_code,
vendor_name=vendor.name, # FIXED: from vendor object vendor_name=vendor.name,
source_url=request.source_url, source_url=request.source_url,
message=f"Marketplace import started from {request.marketplace}. " message=f"Marketplace import started from {request.marketplace}. "
f"Check status with /import-status/{import_job.id}", f"Check status with /import-status/{import_job.id}",
@@ -77,17 +86,16 @@ async def import_products_from_marketplace(
@router.get("/imports/{job_id}", response_model=MarketplaceImportJobResponse) @router.get("/imports/{job_id}", response_model=MarketplaceImportJobResponse)
def get_marketplace_import_status( def get_marketplace_import_status(
job_id: int, job_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Get status of marketplace import job (Protected).""" """Get status of marketplace import job (Protected)."""
vendor = _get_vendor_from_token(current_user, db)
job = marketplace_import_job_service.get_import_job_by_id(db, job_id, current_user) job = marketplace_import_job_service.get_import_job_by_id(db, job_id, current_user)
# Verify job belongs to current vendor # Verify job belongs to current vendor
if job.vendor_id != vendor.id: if job.vendor_id != vendor.id:
from app.exceptions import UnauthorizedVendorAccessException
raise UnauthorizedVendorAccessException(vendor.vendor_code, current_user.id) raise UnauthorizedVendorAccessException(vendor.vendor_code, current_user.id)
return marketplace_import_job_service.convert_to_response_model(job) return marketplace_import_job_service.convert_to_response_model(job)
@@ -98,11 +106,12 @@ def get_marketplace_import_jobs(
marketplace: str | None = Query(None, description="Filter by marketplace"), marketplace: str | None = Query(None, description="Filter by marketplace"),
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100), limit: int = Query(50, ge=1, le=100),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Get marketplace import jobs for current vendor (Protected).""" """Get marketplace import jobs for current vendor (Protected)."""
vendor = _get_vendor_from_token(current_user, db)
jobs = marketplace_import_job_service.get_import_jobs( jobs = marketplace_import_job_service.get_import_jobs(
db=db, db=db,
vendor=vendor, vendor=vendor,

View File

@@ -2,6 +2,8 @@
# app/api/v1/vendor/media.py # app/api/v1/vendor/media.py
""" """
Vendor media and file management endpoints. Vendor media and file management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
""" """
import logging import logging
@@ -11,21 +13,27 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from middleware.vendor_context import require_vendor_context from app.exceptions import InvalidTokenException
from app.services.vendor_service import vendor_service
from models.database.user import User from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter(prefix="/media") router = APIRouter(prefix="/media")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.get("") @router.get("")
def get_media_library( def get_media_library(
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000), limit: int = Query(100, ge=1, le=1000),
media_type: str | None = Query(None, description="image, video, document"), media_type: str | None = Query(None, description="image, video, document"),
search: str | None = Query(None), search: str | None = Query(None),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -39,6 +47,7 @@ def get_media_library(
- Support pagination - Support pagination
- Return file URLs, sizes, metadata - Return file URLs, sizes, metadata
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return { return {
"media": [], "media": [],
"total": 0, "total": 0,
@@ -52,7 +61,6 @@ def get_media_library(
async def upload_media( async def upload_media(
file: UploadFile = File(...), file: UploadFile = File(...),
folder: str | None = Query(None, description="products, general, etc."), folder: str | None = Query(None, description="products, general, etc."),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -67,6 +75,7 @@ async def upload_media(
- Save metadata to database - Save metadata to database
- Return file URL - Return file URL
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return { return {
"file_url": None, "file_url": None,
"thumbnail_url": None, "thumbnail_url": None,
@@ -78,7 +87,6 @@ async def upload_media(
async def upload_multiple_media( async def upload_multiple_media(
files: list[UploadFile] = File(...), files: list[UploadFile] = File(...),
folder: str | None = Query(None), folder: str | None = Query(None),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -91,6 +99,7 @@ async def upload_multiple_media(
- Return list of uploaded file URLs - Return list of uploaded file URLs
- Handle errors gracefully - Handle errors gracefully
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return { return {
"uploaded_files": [], "uploaded_files": [],
"failed_files": [], "failed_files": [],
@@ -101,7 +110,6 @@ async def upload_multiple_media(
@router.get("/{media_id}") @router.get("/{media_id}")
def get_media_details( def get_media_details(
media_id: int, media_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -113,6 +121,7 @@ def get_media_details(
- Return file URL - Return file URL
- Return usage information (which products use this file) - Return usage information (which products use this file)
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Media details coming in Slice 3"} return {"message": "Media details coming in Slice 3"}
@@ -120,7 +129,6 @@ def get_media_details(
def update_media_metadata( def update_media_metadata(
media_id: int, media_id: int,
metadata: dict, metadata: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -133,13 +141,13 @@ def update_media_metadata(
- Update tags/categories - Update tags/categories
- Update description - Update description
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Media update coming in Slice 3"} return {"message": "Media update coming in Slice 3"}
@router.delete("/{media_id}") @router.delete("/{media_id}")
def delete_media( def delete_media(
media_id: int, media_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -153,13 +161,13 @@ def delete_media(
- Delete database record - Delete database record
- Return success/error - Return success/error
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Media deletion coming in Slice 3"} return {"message": "Media deletion coming in Slice 3"}
@router.get("/{media_id}/usage") @router.get("/{media_id}/usage")
def get_media_usage( def get_media_usage(
media_id: int, media_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -171,6 +179,7 @@ def get_media_usage(
- Check other entities using this media - Check other entities using this media
- Return list of usage - Return list of usage
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return { return {
"products": [], "products": [],
"other_usage": [], "other_usage": [],
@@ -181,7 +190,6 @@ def get_media_usage(
@router.post("/optimize/{media_id}") @router.post("/optimize/{media_id}")
def optimize_media( def optimize_media(
media_id: int, media_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -194,4 +202,5 @@ def optimize_media(
- Keep original - Keep original
- Update database with new versions - Update database with new versions
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Media optimization coming in Slice 3"} return {"message": "Media optimization coming in Slice 3"}

View File

@@ -2,6 +2,8 @@
# app/api/v1/vendor/notifications.py # app/api/v1/vendor/notifications.py
""" """
Vendor notification management endpoints. Vendor notification management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
""" """
import logging import logging
@@ -11,20 +13,26 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from middleware.vendor_context import require_vendor_context from app.exceptions import InvalidTokenException
from app.services.vendor_service import vendor_service
from models.database.user import User from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter(prefix="/notifications") router = APIRouter(prefix="/notifications")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.get("") @router.get("")
def get_notifications( def get_notifications(
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100), limit: int = Query(50, ge=1, le=100),
unread_only: bool | None = Query(False), unread_only: bool | None = Query(False),
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -37,6 +45,7 @@ def get_notifications(
- Support pagination - Support pagination
- Return notification details - Return notification details
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return { return {
"notifications": [], "notifications": [],
"total": 0, "total": 0,
@@ -47,7 +56,6 @@ def get_notifications(
@router.get("/unread-count") @router.get("/unread-count")
def get_unread_count( def get_unread_count(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -58,13 +66,13 @@ def get_unread_count(
- Count unread notifications for vendor - Count unread notifications for vendor
- Used for notification badge - Used for notification badge
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"unread_count": 0, "message": "Unread count coming in Slice 5"} return {"unread_count": 0, "message": "Unread count coming in Slice 5"}
@router.put("/{notification_id}/read") @router.put("/{notification_id}/read")
def mark_as_read( def mark_as_read(
notification_id: int, notification_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -75,12 +83,12 @@ def mark_as_read(
- Mark single notification as read - Mark single notification as read
- Update read timestamp - Update read timestamp
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Mark as read coming in Slice 5"} return {"message": "Mark as read coming in Slice 5"}
@router.put("/mark-all-read") @router.put("/mark-all-read")
def mark_all_as_read( def mark_all_as_read(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -91,13 +99,13 @@ def mark_all_as_read(
- Mark all vendor notifications as read - Mark all vendor notifications as read
- Update timestamps - Update timestamps
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Mark all as read coming in Slice 5"} return {"message": "Mark all as read coming in Slice 5"}
@router.delete("/{notification_id}") @router.delete("/{notification_id}")
def delete_notification( def delete_notification(
notification_id: int, notification_id: int,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -108,12 +116,12 @@ def delete_notification(
- Delete single notification - Delete single notification
- Verify notification belongs to vendor - Verify notification belongs to vendor
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Notification deletion coming in Slice 5"} return {"message": "Notification deletion coming in Slice 5"}
@router.get("/settings") @router.get("/settings")
def get_notification_settings( def get_notification_settings(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -125,6 +133,7 @@ def get_notification_settings(
- Get in-app notification settings - Get in-app notification settings
- Get notification types enabled/disabled - Get notification types enabled/disabled
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return { return {
"email_notifications": True, "email_notifications": True,
"in_app_notifications": True, "in_app_notifications": True,
@@ -136,7 +145,6 @@ def get_notification_settings(
@router.put("/settings") @router.put("/settings")
def update_notification_settings( def update_notification_settings(
settings: dict, settings: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -148,12 +156,12 @@ def update_notification_settings(
- Update in-app notification settings - Update in-app notification settings
- Enable/disable specific notification types - Enable/disable specific notification types
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Notification settings update coming in Slice 5"} return {"message": "Notification settings update coming in Slice 5"}
@router.get("/templates") @router.get("/templates")
def get_notification_templates( def get_notification_templates(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -165,6 +173,7 @@ def get_notification_templates(
- Include: order confirmation, shipping notification, etc. - Include: order confirmation, shipping notification, etc.
- Return template details - Return template details
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"templates": [], "message": "Notification templates coming in Slice 5"} return {"templates": [], "message": "Notification templates coming in Slice 5"}
@@ -172,7 +181,6 @@ def get_notification_templates(
def update_notification_template( def update_notification_template(
template_id: int, template_id: int,
template_data: dict, template_data: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -185,13 +193,13 @@ def update_notification_template(
- Validate template variables - Validate template variables
- Preview template - Preview template
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Template update coming in Slice 5"} return {"message": "Template update coming in Slice 5"}
@router.post("/test") @router.post("/test")
def send_test_notification( def send_test_notification(
notification_data: dict, notification_data: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -203,4 +211,5 @@ def send_test_notification(
- Use specified template - Use specified template
- Send to current user's email - Send to current user's email
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Test notification coming in Slice 5"} return {"message": "Test notification coming in Slice 5"}

View File

@@ -2,6 +2,8 @@
# app/api/v1/vendor/payments.py # app/api/v1/vendor/payments.py
""" """
Vendor payment configuration and processing endpoints. Vendor payment configuration and processing endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
""" """
import logging import logging
@@ -11,17 +13,23 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from middleware.vendor_context import require_vendor_context from app.exceptions import InvalidTokenException
from app.services.vendor_service import vendor_service
from models.database.user import User from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter(prefix="/payments") router = APIRouter(prefix="/payments")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.get("/config") @router.get("/config")
def get_payment_configuration( def get_payment_configuration(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -34,6 +42,7 @@ def get_payment_configuration(
- Get currency settings - Get currency settings
- Return masked/secure information only - Return masked/secure information only
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return { return {
"payment_gateway": None, "payment_gateway": None,
"accepted_methods": [], "accepted_methods": [],
@@ -46,7 +55,6 @@ def get_payment_configuration(
@router.put("/config") @router.put("/config")
def update_payment_configuration( def update_payment_configuration(
payment_config: dict, payment_config: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -59,13 +67,13 @@ def update_payment_configuration(
- Update accepted payment methods - Update accepted payment methods
- Validate configuration before saving - Validate configuration before saving
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Payment configuration update coming in Slice 5"} return {"message": "Payment configuration update coming in Slice 5"}
@router.post("/stripe/connect") @router.post("/stripe/connect")
def connect_stripe_account( def connect_stripe_account(
stripe_data: dict, stripe_data: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -78,12 +86,12 @@ def connect_stripe_account(
- Verify Stripe account is active - Verify Stripe account is active
- Enable payment processing - Enable payment processing
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Stripe connection coming in Slice 5"} return {"message": "Stripe connection coming in Slice 5"}
@router.delete("/stripe/disconnect") @router.delete("/stripe/disconnect")
def disconnect_stripe_account( def disconnect_stripe_account(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -95,12 +103,12 @@ def disconnect_stripe_account(
- Disable payment processing - Disable payment processing
- Warn about pending payments - Warn about pending payments
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Stripe disconnection coming in Slice 5"} return {"message": "Stripe disconnection coming in Slice 5"}
@router.get("/methods") @router.get("/methods")
def get_payment_methods( def get_payment_methods(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -111,12 +119,12 @@ def get_payment_methods(
- Return list of enabled payment methods - Return list of enabled payment methods
- Include: credit card, PayPal, bank transfer, etc. - Include: credit card, PayPal, bank transfer, etc.
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"methods": [], "message": "Payment methods coming in Slice 5"} return {"methods": [], "message": "Payment methods coming in Slice 5"}
@router.get("/transactions") @router.get("/transactions")
def get_payment_transactions( def get_payment_transactions(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -129,6 +137,7 @@ def get_payment_transactions(
- Include payment details - Include payment details
- Support pagination - Support pagination
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return { return {
"transactions": [], "transactions": [],
"total": 0, "total": 0,
@@ -138,7 +147,6 @@ def get_payment_transactions(
@router.get("/balance") @router.get("/balance")
def get_payment_balance( def get_payment_balance(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -151,6 +159,7 @@ def get_payment_balance(
- Get next payout date - Get next payout date
- Get payout history - Get payout history
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return { return {
"available_balance": 0.0, "available_balance": 0.0,
"pending_balance": 0.0, "pending_balance": 0.0,
@@ -164,7 +173,6 @@ def get_payment_balance(
def refund_payment( def refund_payment(
payment_id: int, payment_id: int,
refund_data: dict, refund_data: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -177,4 +185,5 @@ def refund_payment(
- Update order status - Update order status
- Send refund notification to customer - Send refund notification to customer
""" """
vendor = _get_vendor_from_token(current_user, db) # noqa: F841 - vendor will be used when implemented
return {"message": "Payment refund coming in Slice 5"} return {"message": "Payment refund coming in Slice 5"}

View File

@@ -10,13 +10,16 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from app.exceptions import InvalidTokenException
from app.services.product_service import product_service from app.services.product_service import product_service
from models.database.user import User from models.database.user import User
from models.schema.product import ( from models.schema.product import (
ProductCreate, ProductCreate,
ProductDeleteResponse,
ProductDetailResponse, ProductDetailResponse,
ProductListResponse, ProductListResponse,
ProductResponse, ProductResponse,
ProductToggleResponse,
ProductUpdate, ProductUpdate,
) )
@@ -42,14 +45,9 @@ def get_vendor_products(
Vendor is determined from JWT token (vendor_id claim). Vendor is determined from JWT token (vendor_id claim).
""" """
from fastapi import HTTPException
# Get vendor ID from token # Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"): if not hasattr(current_user, "token_vendor_id"):
raise HTTPException( raise InvalidTokenException("Token missing vendor information. Please login again.")
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id vendor_id = current_user.token_vendor_id
@@ -77,14 +75,9 @@ def get_product_details(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Get detailed product information including inventory.""" """Get detailed product information including inventory."""
from fastapi import HTTPException
# Get vendor ID from token # Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"): if not hasattr(current_user, "token_vendor_id"):
raise HTTPException( raise InvalidTokenException("Token missing vendor information. Please login again.")
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id vendor_id = current_user.token_vendor_id
@@ -106,14 +99,9 @@ def add_product_to_catalog(
This publishes a MarketplaceProduct to the vendor's public catalog. This publishes a MarketplaceProduct to the vendor's public catalog.
""" """
from fastapi import HTTPException
# Get vendor ID from token # Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"): if not hasattr(current_user, "token_vendor_id"):
raise HTTPException( raise InvalidTokenException("Token missing vendor information. Please login again.")
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id vendor_id = current_user.token_vendor_id
@@ -137,14 +125,9 @@ def update_product(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Update product in vendor catalog.""" """Update product in vendor catalog."""
from fastapi import HTTPException
# Get vendor ID from token # Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"): if not hasattr(current_user, "token_vendor_id"):
raise HTTPException( raise InvalidTokenException("Token missing vendor information. Please login again.")
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id vendor_id = current_user.token_vendor_id
@@ -160,21 +143,16 @@ def update_product(
return ProductResponse.model_validate(product) return ProductResponse.model_validate(product)
@router.delete("/{product_id}") @router.delete("/{product_id}", response_model=ProductDeleteResponse)
def remove_product_from_catalog( def remove_product_from_catalog(
product_id: int, product_id: int,
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Remove product from vendor catalog.""" """Remove product from vendor catalog."""
from fastapi import HTTPException
# Get vendor ID from token # Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"): if not hasattr(current_user, "token_vendor_id"):
raise HTTPException( raise InvalidTokenException("Token missing vendor information. Please login again.")
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id vendor_id = current_user.token_vendor_id
@@ -185,7 +163,7 @@ def remove_product_from_catalog(
f"for vendor {current_user.token_vendor_code}" f"for vendor {current_user.token_vendor_code}"
) )
return {"message": f"Product {product_id} removed from catalog"} return ProductDeleteResponse(message=f"Product {product_id} removed from catalog")
@router.post("/from-import/{marketplace_product_id}", response_model=ProductResponse) @router.post("/from-import/{marketplace_product_id}", response_model=ProductResponse)
@@ -199,14 +177,9 @@ def publish_from_marketplace(
Shortcut endpoint for publishing directly from marketplace import. Shortcut endpoint for publishing directly from marketplace import.
""" """
from fastapi import HTTPException
# Get vendor ID from token # Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"): if not hasattr(current_user, "token_vendor_id"):
raise HTTPException( raise InvalidTokenException("Token missing vendor information. Please login again.")
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id vendor_id = current_user.token_vendor_id
@@ -226,21 +199,16 @@ def publish_from_marketplace(
return ProductResponse.model_validate(product) return ProductResponse.model_validate(product)
@router.put("/{product_id}/toggle-active") @router.put("/{product_id}/toggle-active", response_model=ProductToggleResponse)
def toggle_product_active( def toggle_product_active(
product_id: int, product_id: int,
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Toggle product active status.""" """Toggle product active status."""
from fastapi import HTTPException
# Get vendor ID from token # Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"): if not hasattr(current_user, "token_vendor_id"):
raise HTTPException( raise InvalidTokenException("Token missing vendor information. Please login again.")
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id vendor_id = current_user.token_vendor_id
@@ -253,24 +221,19 @@ def toggle_product_active(
status = "activated" if product.is_active else "deactivated" status = "activated" if product.is_active else "deactivated"
logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}") logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}")
return {"message": f"Product {status}", "is_active": product.is_active} return ProductToggleResponse(message=f"Product {status}", is_active=product.is_active)
@router.put("/{product_id}/toggle-featured") @router.put("/{product_id}/toggle-featured", response_model=ProductToggleResponse)
def toggle_product_featured( def toggle_product_featured(
product_id: int, product_id: int,
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Toggle product featured status.""" """Toggle product featured status."""
from fastapi import HTTPException
# Get vendor ID from token # Get vendor ID from token
if not hasattr(current_user, "token_vendor_id"): if not hasattr(current_user, "token_vendor_id"):
raise HTTPException( raise InvalidTokenException("Token missing vendor information. Please login again.")
status_code=400,
detail="Token missing vendor information. Please login again.",
)
vendor_id = current_user.token_vendor_id vendor_id = current_user.token_vendor_id
@@ -283,4 +246,4 @@ def toggle_product_featured(
status = "featured" if product.is_featured else "unfeatured" status = "featured" if product.is_featured else "unfeatured"
logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}") logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}")
return {"message": f"Product {status}", "is_featured": product.is_featured} return ProductToggleResponse(message=f"Product {status}", is_featured=product.is_featured)

View File

@@ -1,45 +1,54 @@
# app/api/v1/vendor/profile.py # app/api/v1/vendor/profile.py
""" """
Vendor profile management endpoints. Vendor profile management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
""" """
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from app.exceptions import InsufficientPermissionsException, InvalidTokenException
from app.services.vendor_service import vendor_service from app.services.vendor_service import vendor_service
from middleware.vendor_context import require_vendor_context
from models.database.user import User from models.database.user import User
from models.database.vendor import Vendor
from models.schema.vendor import VendorResponse, VendorUpdate from models.schema.vendor import VendorResponse, VendorUpdate
router = APIRouter(prefix="/profile") router = APIRouter(prefix="/profile")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _get_vendor_from_token(current_user: User, db: Session):
"""Helper to get vendor from JWT token."""
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
return vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
@router.get("", response_model=VendorResponse) @router.get("", response_model=VendorResponse)
def get_vendor_profile( def get_vendor_profile(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Get current vendor profile information.""" """Get current vendor profile information."""
vendor = _get_vendor_from_token(current_user, db)
return vendor return vendor
@router.put("", response_model=VendorResponse) @router.put("", response_model=VendorResponse)
def update_vendor_profile( def update_vendor_profile(
vendor_update: VendorUpdate, vendor_update: VendorUpdate,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Update vendor profile information.""" """Update vendor profile information."""
vendor = _get_vendor_from_token(current_user, db)
# Verify user has permission to update vendor # Verify user has permission to update vendor
if not vendor_service.can_update_vendor(vendor, current_user): if not vendor_service.can_update_vendor(vendor, current_user):
raise HTTPException(status_code=403, detail="Insufficient permissions") raise InsufficientPermissionsException(required_permission="vendor:profile:update")
return vendor_service.update_vendor(db, vendor.id, vendor_update) return vendor_service.update_vendor(db, vendor.id, vendor_update)

View File

@@ -1,19 +1,20 @@
# app/api/v1/vendor/settings.py # app/api/v1/vendor/settings.py
""" """
Vendor settings and configuration endpoints. Vendor settings and configuration endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern)
""" """
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from app.exceptions import InsufficientPermissionsException, InvalidTokenException
from app.services.vendor_service import vendor_service from app.services.vendor_service import vendor_service
from middleware.vendor_context import require_vendor_context
from models.database.user import User from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter(prefix="/settings") router = APIRouter(prefix="/settings")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -21,11 +22,16 @@ logger = logging.getLogger(__name__)
@router.get("") @router.get("")
def get_vendor_settings( def get_vendor_settings(
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Get vendor settings and configuration.""" """Get vendor settings and configuration."""
# Get vendor ID from JWT token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
return { return {
"vendor_code": vendor.vendor_code, "vendor_code": vendor.vendor_code,
"subdomain": vendor.subdomain, "subdomain": vendor.subdomain,
@@ -46,14 +52,21 @@ def get_vendor_settings(
@router.put("/marketplace") @router.put("/marketplace")
def update_marketplace_settings( def update_marketplace_settings(
marketplace_config: dict, marketplace_config: dict,
vendor: Vendor = Depends(require_vendor_context()),
current_user: User = Depends(get_current_vendor_api), current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Update marketplace integration settings.""" """Update marketplace integration settings."""
# Get vendor ID from JWT token
if not hasattr(current_user, "token_vendor_id"):
raise InvalidTokenException("Token missing vendor information. Please login again.")
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
# Verify permissions # Verify permissions
if not vendor_service.can_update_vendor(vendor, current_user): if not vendor_service.can_update_vendor(vendor, current_user):
raise HTTPException(status_code=403, detail="Insufficient permissions") raise InsufficientPermissionsException(
required_permission="vendor:settings:update"
)
# Update Letzshop URLs # Update Letzshop URLs
if "letzshop_csv_url_fr" in marketplace_config: if "letzshop_csv_url_fr" in marketplace_config:

View File

@@ -14,7 +14,9 @@ from .admin import (
CannotModifySelfException, CannotModifySelfException,
ConfirmationRequiredException, ConfirmationRequiredException,
InvalidAdminActionException, InvalidAdminActionException,
UserCannotBeDeletedException,
UserNotFoundException, UserNotFoundException,
UserRoleChangeException,
UserStatusChangeException, UserStatusChangeException,
VendorVerificationException, VendorVerificationException,
) )
@@ -44,6 +46,17 @@ from .base import (
WizamartException, WizamartException,
) )
# Code quality exceptions
from .code_quality import (
InvalidViolationStatusException,
ScanExecutionException,
ScanNotFoundException,
ScanParseException,
ScanTimeoutException,
ViolationNotFoundException,
ViolationOperationException,
)
# Cart exceptions # Cart exceptions
from .cart import ( from .cart import (
CartItemNotFoundException, CartItemNotFoundException,
@@ -155,13 +168,16 @@ from .team import (
# Vendor exceptions # Vendor exceptions
from .vendor import ( from .vendor import (
InsufficientVendorPermissionsException,
InvalidVendorDataException, InvalidVendorDataException,
MaxVendorsReachedException, MaxVendorsReachedException,
UnauthorizedVendorAccessException, UnauthorizedVendorAccessException,
VendorAccessDeniedException,
VendorAlreadyExistsException, VendorAlreadyExistsException,
VendorNotActiveException, VendorNotActiveException,
VendorNotFoundException, VendorNotFoundException,
VendorNotVerifiedException, VendorNotVerifiedException,
VendorOwnerOnlyException,
VendorValidationException, VendorValidationException,
) )
@@ -245,13 +261,16 @@ __all__ = [
"InvalidQuantityException", "InvalidQuantityException",
"LocationNotFoundException", "LocationNotFoundException",
# Vendor exceptions # Vendor exceptions
"VendorNotFoundException", "InsufficientVendorPermissionsException",
"VendorAlreadyExistsException",
"VendorNotActiveException",
"VendorNotVerifiedException",
"UnauthorizedVendorAccessException",
"InvalidVendorDataException", "InvalidVendorDataException",
"MaxVendorsReachedException", "MaxVendorsReachedException",
"UnauthorizedVendorAccessException",
"VendorAccessDeniedException",
"VendorAlreadyExistsException",
"VendorNotActiveException",
"VendorNotFoundException",
"VendorNotVerifiedException",
"VendorOwnerOnlyException",
"VendorValidationException", "VendorValidationException",
# Vendor Domain # Vendor Domain
"VendorDomainNotFoundException", "VendorDomainNotFoundException",
@@ -334,4 +353,12 @@ __all__ = [
"InvalidAdminActionException", "InvalidAdminActionException",
"BulkOperationException", "BulkOperationException",
"ConfirmationRequiredException", "ConfirmationRequiredException",
# Code quality exceptions
"ViolationNotFoundException",
"ScanNotFoundException",
"ScanExecutionException",
"ScanTimeoutException",
"ScanParseException",
"ViolationOperationException",
"InvalidViolationStatusException",
] ]

View File

@@ -236,3 +236,37 @@ class VendorVerificationException(BusinessLogicException):
error_code="VENDOR_VERIFICATION_FAILED", error_code="VENDOR_VERIFICATION_FAILED",
details=details, details=details,
) )
class UserCannotBeDeletedException(BusinessLogicException):
"""Raised when a user cannot be deleted due to ownership constraints."""
def __init__(self, user_id: int, reason: str, owned_count: int = 0):
details = {
"user_id": user_id,
"reason": reason,
}
if owned_count > 0:
details["owned_companies_count"] = owned_count
super().__init__(
message=f"Cannot delete user {user_id}: {reason}",
error_code="USER_CANNOT_BE_DELETED",
details=details,
)
class UserRoleChangeException(BusinessLogicException):
"""Raised when user role cannot be changed."""
def __init__(self, user_id: int, current_role: str, target_role: str, reason: str):
super().__init__(
message=f"Cannot change user {user_id} role from {current_role} to {target_role}: {reason}",
error_code="USER_ROLE_CHANGE_FAILED",
details={
"user_id": user_id,
"current_role": current_role,
"target_role": target_role,
"reason": reason,
},
)

View File

@@ -0,0 +1,95 @@
# app/exceptions/code_quality.py
"""
Code Quality Domain Exceptions
These exceptions are raised by the code quality service layer
and converted to HTTP responses by the global exception handler.
"""
from app.exceptions.base import (
BusinessLogicException,
ExternalServiceException,
ResourceNotFoundException,
ValidationException,
)
class ViolationNotFoundException(ResourceNotFoundException):
"""Raised when a violation is not found."""
def __init__(self, violation_id: int):
super().__init__(
resource_type="Violation",
identifier=str(violation_id),
error_code="VIOLATION_NOT_FOUND",
)
class ScanNotFoundException(ResourceNotFoundException):
"""Raised when a scan is not found."""
def __init__(self, scan_id: int):
super().__init__(
resource_type="Scan",
identifier=str(scan_id),
error_code="SCAN_NOT_FOUND",
)
class ScanExecutionException(ExternalServiceException):
"""Raised when architecture scan execution fails."""
def __init__(self, reason: str):
super().__init__(
service_name="ArchitectureValidator",
message=f"Scan execution failed: {reason}",
error_code="SCAN_EXECUTION_FAILED",
)
class ScanTimeoutException(ExternalServiceException):
"""Raised when architecture scan times out."""
def __init__(self, timeout_seconds: int = 300):
super().__init__(
service_name="ArchitectureValidator",
message=f"Scan timed out after {timeout_seconds} seconds",
error_code="SCAN_TIMEOUT",
)
class ScanParseException(BusinessLogicException):
"""Raised when scan results cannot be parsed."""
def __init__(self, reason: str):
super().__init__(
message=f"Failed to parse scan results: {reason}",
error_code="SCAN_PARSE_FAILED",
)
class ViolationOperationException(BusinessLogicException):
"""Raised when a violation operation fails."""
def __init__(self, operation: str, violation_id: int, reason: str):
super().__init__(
message=f"Failed to {operation} violation {violation_id}: {reason}",
error_code="VIOLATION_OPERATION_FAILED",
details={
"operation": operation,
"violation_id": violation_id,
"reason": reason,
},
)
class InvalidViolationStatusException(ValidationException):
"""Raised when a violation status transition is invalid."""
def __init__(self, violation_id: int, current_status: str, target_status: str):
super().__init__(
message=f"Cannot change violation {violation_id} from '{current_status}' to '{target_status}'",
field="status",
value=target_status,
)
self.error_code = "INVALID_VIOLATION_STATUS"

View File

@@ -0,0 +1,82 @@
# app/exceptions/content_page.py
"""
Content Page Domain Exceptions
These exceptions are raised by the content page service layer
and converted to HTTP responses by the global exception handler.
"""
from app.exceptions.base import (
AuthorizationException,
BusinessLogicException,
ConflictException,
ResourceNotFoundException,
ValidationException,
)
class ContentPageNotFoundException(ResourceNotFoundException):
"""Raised when a content page is not found."""
def __init__(self, identifier: str | int | None = None):
if identifier:
message = f"Content page not found: {identifier}"
else:
message = "Content page not found"
super().__init__(message=message, resource_type="content_page")
class ContentPageAlreadyExistsException(ConflictException):
"""Raised when a content page with the same slug already exists."""
def __init__(self, slug: str, vendor_id: int | None = None):
if vendor_id:
message = f"Content page with slug '{slug}' already exists for this vendor"
else:
message = f"Platform content page with slug '{slug}' already exists"
super().__init__(message=message)
class ContentPageSlugReservedException(ValidationException):
"""Raised when trying to use a reserved slug."""
def __init__(self, slug: str):
super().__init__(
message=f"Content page slug '{slug}' is reserved",
field="slug",
value=slug,
)
class ContentPageNotPublishedException(BusinessLogicException):
"""Raised when trying to access an unpublished content page."""
def __init__(self, slug: str):
super().__init__(message=f"Content page '{slug}' is not published")
class UnauthorizedContentPageAccessException(AuthorizationException):
"""Raised when a user tries to access/modify a content page they don't own."""
def __init__(self, action: str = "access"):
super().__init__(
message=f"Cannot {action} content pages from other vendors",
required_permission=f"content_page:{action}",
)
class VendorNotAssociatedException(AuthorizationException):
"""Raised when a user is not associated with a vendor."""
def __init__(self):
super().__init__(
message="User is not associated with a vendor",
required_permission="vendor:member",
)
class ContentPageValidationException(ValidationException):
"""Raised when content page data validation fails."""
def __init__(self, field: str, message: str, value: str | None = None):
super().__init__(message=message, field=field, value=value)

View File

@@ -148,3 +148,43 @@ class MaxVendorsReachedException(BusinessLogicException):
error_code="MAX_VENDORS_REACHED", error_code="MAX_VENDORS_REACHED",
details=details, details=details,
) )
class VendorAccessDeniedException(AuthorizationException):
"""Raised when no vendor context is available for an authenticated endpoint."""
def __init__(self, message: str = "No vendor context available"):
super().__init__(
message=message,
error_code="VENDOR_ACCESS_DENIED",
)
class VendorOwnerOnlyException(AuthorizationException):
"""Raised when operation requires vendor owner role."""
def __init__(self, operation: str, vendor_code: str | None = None):
details = {"operation": operation}
if vendor_code:
details["vendor_code"] = vendor_code
super().__init__(
message=f"Operation '{operation}' requires vendor owner role",
error_code="VENDOR_OWNER_ONLY",
details=details,
)
class InsufficientVendorPermissionsException(AuthorizationException):
"""Raised when user lacks required vendor permission."""
def __init__(self, required_permission: str, vendor_code: str | None = None):
details = {"required_permission": required_permission}
if vendor_code:
details["vendor_code"] = vendor_code
super().__init__(
message=f"Permission required: {required_permission}",
error_code="INSUFFICIENT_VENDOR_PERMISSIONS",
details=details,
)

View File

@@ -21,13 +21,17 @@ from sqlalchemy.orm import Session, joinedload
from app.exceptions import ( from app.exceptions import (
AdminOperationException, AdminOperationException,
CannotModifySelfException, CannotModifySelfException,
UserCannotBeDeletedException,
UserNotFoundException, UserNotFoundException,
UserRoleChangeException,
UserStatusChangeException, UserStatusChangeException,
ValidationException, ValidationException,
VendorAlreadyExistsException, VendorAlreadyExistsException,
VendorNotFoundException, VendorNotFoundException,
VendorVerificationException, VendorVerificationException,
) )
from app.exceptions.auth import UserAlreadyExistsException
from middleware.auth import AuthManager
from models.database.company import Company from models.database.company import Company
from models.database.marketplace_import_job import MarketplaceImportJob from models.database.marketplace_import_job import MarketplaceImportJob
from models.database.user import User from models.database.user import User
@@ -97,6 +101,244 @@ class AdminService:
reason="Database update failed", reason="Database update failed",
) )
def list_users(
self,
db: Session,
page: int = 1,
per_page: int = 10,
search: str | None = None,
role: str | None = None,
is_active: bool | None = None,
) -> tuple[list[User], int, int]:
"""
Get paginated list of users with filtering.
Returns:
Tuple of (users, total_count, total_pages)
"""
import math
query = db.query(User)
# Apply filters
if search:
search_term = f"%{search.lower()}%"
query = query.filter(
or_(
User.username.ilike(search_term),
User.email.ilike(search_term),
User.first_name.ilike(search_term),
User.last_name.ilike(search_term),
)
)
if role:
query = query.filter(User.role == role)
if is_active is not None:
query = query.filter(User.is_active == is_active)
# Get total count
total = query.count()
pages = math.ceil(total / per_page) if total > 0 else 1
# Apply pagination
skip = (page - 1) * per_page
users = query.order_by(User.created_at.desc()).offset(skip).limit(per_page).all()
return users, total, pages
def create_user(
self,
db: Session,
email: str,
username: str,
password: str,
first_name: str | None = None,
last_name: str | None = None,
role: str = "customer",
current_admin_id: int | None = None,
) -> User:
"""
Create a new user.
Raises:
UserAlreadyExistsException: If email or username already exists
"""
# Check if email exists
if db.query(User).filter(User.email == email).first():
raise UserAlreadyExistsException("Email already registered", field="email")
# Check if username exists
if db.query(User).filter(User.username == username).first():
raise UserAlreadyExistsException("Username already taken", field="username")
# Create user
auth_manager = AuthManager()
user = User(
email=email,
username=username,
hashed_password=auth_manager.hash_password(password),
first_name=first_name,
last_name=last_name,
role=role,
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
logger.info(f"Admin {current_admin_id} created user {user.username}")
return user
def get_user_details(self, db: Session, user_id: int) -> User:
"""
Get user with relationships loaded.
Raises:
UserNotFoundException: If user not found
"""
user = (
db.query(User)
.options(joinedload(User.owned_companies), joinedload(User.vendor_memberships))
.filter(User.id == user_id)
.first()
)
if not user:
raise UserNotFoundException(str(user_id))
return user
def update_user(
self,
db: Session,
user_id: int,
current_admin_id: int,
email: str | None = None,
username: str | None = None,
first_name: str | None = None,
last_name: str | None = None,
role: str | None = None,
is_active: bool | None = None,
) -> User:
"""
Update user information.
Raises:
UserNotFoundException: If user not found
UserAlreadyExistsException: If email/username already taken
UserRoleChangeException: If trying to change own admin role
"""
user = self._get_user_by_id_or_raise(db, user_id)
# Prevent changing own admin status
if user.id == current_admin_id and role and role != "admin":
raise UserRoleChangeException(
user_id=user_id,
current_role=user.role,
target_role=role,
reason="Cannot change your own admin role",
)
# Check email uniqueness if changing
if email and email != user.email:
if db.query(User).filter(User.email == email).first():
raise UserAlreadyExistsException("Email already registered", field="email")
# Check username uniqueness if changing
if username and username != user.username:
if db.query(User).filter(User.username == username).first():
raise UserAlreadyExistsException("Username already taken", field="username")
# Update fields
if email is not None:
user.email = email
if username is not None:
user.username = username
if first_name is not None:
user.first_name = first_name
if last_name is not None:
user.last_name = last_name
if role is not None:
user.role = role
if is_active is not None:
user.is_active = is_active
user.updated_at = datetime.now(UTC)
db.commit()
db.refresh(user)
logger.info(f"Admin {current_admin_id} updated user {user.username}")
return user
def delete_user(self, db: Session, user_id: int, current_admin_id: int) -> str:
"""
Delete a user.
Raises:
UserNotFoundException: If user not found
CannotModifySelfException: If trying to delete yourself
UserCannotBeDeletedException: If user owns companies
"""
user = (
db.query(User)
.options(joinedload(User.owned_companies))
.filter(User.id == user_id)
.first()
)
if not user:
raise UserNotFoundException(str(user_id))
# Prevent deleting yourself
if user.id == current_admin_id:
raise CannotModifySelfException(user_id, "delete account")
# Prevent deleting users who own companies
if user.owned_companies:
raise UserCannotBeDeletedException(
user_id=user_id,
reason=f"User owns {len(user.owned_companies)} company(ies). Transfer ownership first.",
owned_count=len(user.owned_companies),
)
username = user.username
db.delete(user)
db.commit()
logger.info(f"Admin {current_admin_id} deleted user {username}")
return f"User {username} deleted successfully"
def search_users(
self,
db: Session,
query: str,
limit: int = 10,
) -> list[dict]:
"""
Search users by username or email.
Used for autocomplete in ownership transfer.
"""
search_term = f"%{query.lower()}%"
users = (
db.query(User)
.filter(or_(User.username.ilike(search_term), User.email.ilike(search_term)))
.limit(limit)
.all()
)
return [
{
"id": user.id,
"username": user.username,
"email": user.email,
"is_active": user.is_active,
}
for user in users
]
# ============================================================================ # ============================================================================
# VENDOR MANAGEMENT # VENDOR MANAGEMENT
# ============================================================================ # ============================================================================

View File

@@ -22,6 +22,7 @@ from app.exceptions import (
) )
from middleware.auth import AuthManager from middleware.auth import AuthManager
from models.database.user import User from models.database.user import User
from models.database.vendor import Vendor, VendorUser
from models.schema.auth import UserLogin, UserRegister from models.schema.auth import UserLogin, UserRegister
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -214,6 +215,84 @@ class AuthService:
logger.error(f"Error creating access token with data: {str(e)}") logger.error(f"Error creating access token with data: {str(e)}")
raise ValidationException("Failed to create access token") raise ValidationException("Failed to create access token")
def get_vendor_by_code(self, db: Session, vendor_code: str) -> Vendor | None:
"""
Get active vendor by vendor code.
Args:
db: Database session
vendor_code: Vendor code to look up
Returns:
Vendor if found and active, None otherwise
"""
return (
db.query(Vendor)
.filter(Vendor.vendor_code == vendor_code.upper(), Vendor.is_active == True)
.first()
)
def get_user_vendor_role(
self, db: Session, user: User, vendor: Vendor
) -> tuple[bool, str | None]:
"""
Check if user has access to vendor and return their role.
Args:
db: Database session
user: User to check
vendor: Vendor to check access for
Returns:
Tuple of (has_access: bool, role_name: str | None)
"""
# Check if user is vendor owner (via company ownership)
if vendor.company and vendor.company.owner_user_id == user.id:
return True, "Owner"
# Check if user is team member
vendor_user = (
db.query(VendorUser)
.filter(
VendorUser.user_id == user.id,
VendorUser.vendor_id == vendor.id,
VendorUser.is_active == True,
)
.first()
)
if vendor_user:
return True, vendor_user.role.name
return False, None
def find_user_vendor(self, user: User) -> tuple[Vendor | None, str | None]:
"""
Find which vendor a user belongs to when no vendor context is provided.
Checks owned companies first, then vendor memberships.
Args:
user: User to find vendor for
Returns:
Tuple of (vendor: Vendor | None, role: str | None)
"""
# Check owned vendors first (via company ownership)
for company in user.owned_companies:
if company.vendors:
return company.vendors[0], "Owner"
# Check vendor memberships
if user.vendor_memberships:
active_membership = next(
(vm for vm in user.vendor_memberships if vm.is_active), None
)
if active_membership:
return active_membership.vendor, active_membership.role.name
return None, None
# Private helper methods # Private helper methods
def _email_exists(self, db: Session, email: str) -> bool: def _email_exists(self, db: Session, email: str) -> bool:
"""Check if email already exists.""" """Check if email already exists."""

View File

@@ -11,6 +11,11 @@ from datetime import datetime
from sqlalchemy import desc, func from sqlalchemy import desc, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.exceptions import (
ScanParseException,
ScanTimeoutException,
ViolationNotFoundException,
)
from models.database.architecture_scan import ( from models.database.architecture_scan import (
ArchitectureScan, ArchitectureScan,
ArchitectureViolation, ArchitectureViolation,
@@ -54,7 +59,7 @@ class CodeQualityService:
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
logger.error("Architecture scan timed out after 5 minutes") logger.error("Architecture scan timed out after 5 minutes")
raise Exception("Scan timed out") raise ScanTimeoutException(timeout_seconds=300)
duration = (datetime.now() - start_time).total_seconds() duration = (datetime.now() - start_time).total_seconds()
@@ -77,7 +82,7 @@ class CodeQualityService:
logger.error(f"Failed to parse validator output: {e}") logger.error(f"Failed to parse validator output: {e}")
logger.error(f"Stdout: {result.stdout}") logger.error(f"Stdout: {result.stdout}")
logger.error(f"Stderr: {result.stderr}") logger.error(f"Stderr: {result.stderr}")
raise Exception(f"Failed to parse scan results: {e}") raise ScanParseException(reason=str(e))
# Create scan record # Create scan record
scan = ArchitectureScan( scan = ArchitectureScan(
@@ -285,7 +290,7 @@ class CodeQualityService:
""" """
violation = self.get_violation_by_id(db, violation_id) violation = self.get_violation_by_id(db, violation_id)
if not violation: if not violation:
raise ValueError(f"Violation {violation_id} not found") raise ViolationNotFoundException(violation_id)
violation.status = "resolved" violation.status = "resolved"
violation.resolved_at = datetime.now() violation.resolved_at = datetime.now()
@@ -313,7 +318,7 @@ class CodeQualityService:
""" """
violation = self.get_violation_by_id(db, violation_id) violation = self.get_violation_by_id(db, violation_id)
if not violation: if not violation:
raise ValueError(f"Violation {violation_id} not found") raise ViolationNotFoundException(violation_id)
violation.status = "ignored" violation.status = "ignored"
violation.resolved_at = datetime.now() violation.resolved_at = datetime.now()

View File

@@ -22,6 +22,10 @@ from datetime import UTC, datetime
from sqlalchemy import and_ from sqlalchemy import and_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.exceptions.content_page import (
ContentPageNotFoundException,
UnauthorizedContentPageAccessException,
)
from models.database.content_page import ContentPage from models.database.content_page import ContentPage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -319,6 +323,214 @@ class ContentPageService:
"""Get content page by ID.""" """Get content page by ID."""
return db.query(ContentPage).filter(ContentPage.id == page_id).first() return db.query(ContentPage).filter(ContentPage.id == page_id).first()
@staticmethod
def get_page_by_id_or_raise(db: Session, page_id: int) -> ContentPage:
"""
Get content page by ID or raise ContentPageNotFoundException.
Args:
db: Database session
page_id: Page ID
Returns:
ContentPage
Raises:
ContentPageNotFoundException: If page not found
"""
page = db.query(ContentPage).filter(ContentPage.id == page_id).first()
if not page:
raise ContentPageNotFoundException(identifier=page_id)
return page
@staticmethod
def get_page_for_vendor_or_raise(
db: Session,
slug: str,
vendor_id: int | None = None,
include_unpublished: bool = False,
) -> ContentPage:
"""
Get content page for a vendor with fallback to platform default.
Raises ContentPageNotFoundException if not found.
Args:
db: Database session
slug: Page slug
vendor_id: Vendor ID
include_unpublished: Include draft pages
Returns:
ContentPage
Raises:
ContentPageNotFoundException: If page not found
"""
page = ContentPageService.get_page_for_vendor(
db, slug=slug, vendor_id=vendor_id, include_unpublished=include_unpublished
)
if not page:
raise ContentPageNotFoundException(identifier=slug)
return page
@staticmethod
def update_page_or_raise(
db: Session,
page_id: int,
title: str | None = None,
content: str | None = None,
content_format: str | None = None,
template: str | None = None,
meta_description: str | None = None,
meta_keywords: str | None = None,
is_published: bool | None = None,
show_in_footer: bool | None = None,
show_in_header: bool | None = None,
display_order: int | None = None,
updated_by: int | None = None,
) -> ContentPage:
"""
Update an existing content page or raise exception.
Raises:
ContentPageNotFoundException: If page not found
"""
page = ContentPageService.update_page(
db,
page_id=page_id,
title=title,
content=content,
content_format=content_format,
template=template,
meta_description=meta_description,
meta_keywords=meta_keywords,
is_published=is_published,
show_in_footer=show_in_footer,
show_in_header=show_in_header,
display_order=display_order,
updated_by=updated_by,
)
if not page:
raise ContentPageNotFoundException(identifier=page_id)
return page
@staticmethod
def delete_page_or_raise(db: Session, page_id: int) -> None:
"""
Delete a content page or raise exception.
Raises:
ContentPageNotFoundException: If page not found
"""
success = ContentPageService.delete_page(db, page_id)
if not success:
raise ContentPageNotFoundException(identifier=page_id)
@staticmethod
def update_vendor_page(
db: Session,
page_id: int,
vendor_id: int,
title: str | None = None,
content: str | None = None,
content_format: str | None = None,
meta_description: str | None = None,
meta_keywords: str | None = None,
is_published: bool | None = None,
show_in_footer: bool | None = None,
show_in_header: bool | None = None,
display_order: int | None = None,
updated_by: int | None = None,
) -> ContentPage:
"""
Update a vendor-specific content page with ownership check.
Args:
db: Database session
page_id: Page ID
vendor_id: Vendor ID (for ownership verification)
... other fields
Returns:
Updated ContentPage
Raises:
ContentPageNotFoundException: If page not found
UnauthorizedContentPageAccessException: If page doesn't belong to vendor
"""
page = ContentPageService.get_page_by_id_or_raise(db, page_id)
if page.vendor_id != vendor_id:
raise UnauthorizedContentPageAccessException(action="edit")
return ContentPageService.update_page_or_raise(
db,
page_id=page_id,
title=title,
content=content,
content_format=content_format,
meta_description=meta_description,
meta_keywords=meta_keywords,
is_published=is_published,
show_in_footer=show_in_footer,
show_in_header=show_in_header,
display_order=display_order,
updated_by=updated_by,
)
@staticmethod
def delete_vendor_page(db: Session, page_id: int, vendor_id: int) -> None:
"""
Delete a vendor-specific content page with ownership check.
Args:
db: Database session
page_id: Page ID
vendor_id: Vendor ID (for ownership verification)
Raises:
ContentPageNotFoundException: If page not found
UnauthorizedContentPageAccessException: If page doesn't belong to vendor
"""
page = ContentPageService.get_page_by_id_or_raise(db, page_id)
if page.vendor_id != vendor_id:
raise UnauthorizedContentPageAccessException(action="delete")
ContentPageService.delete_page_or_raise(db, page_id)
@staticmethod
def list_all_pages(
db: Session,
vendor_id: int | None = None,
include_unpublished: bool = False,
) -> list[ContentPage]:
"""
List all content pages (platform defaults and vendor overrides).
Args:
db: Database session
vendor_id: Optional filter by vendor ID
include_unpublished: Include draft pages
Returns:
List of ContentPage objects
"""
filters = []
if vendor_id:
filters.append(ContentPage.vendor_id == vendor_id)
if not include_unpublished:
filters.append(ContentPage.is_published == True)
return (
db.query(ContentPage)
.filter(and_(*filters) if filters else True)
.order_by(ContentPage.vendor_id, ContentPage.display_order, ContentPage.title)
.all()
)
@staticmethod @staticmethod
def list_all_vendor_pages( def list_all_vendor_pages(
db: Session, vendor_id: int, include_unpublished: bool = False db: Session, vendor_id: int, include_unpublished: bool = False

View File

@@ -1,6 +1,6 @@
{# app/templates/admin/base.html #} {# app/templates/admin/base.html #}
<!DOCTYPE html> <!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en"> <html :class="{ 'dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -10,11 +10,7 @@
<link href="/static/shared/fonts/inter.css" rel="stylesheet" /> <link href="/static/shared/fonts/inter.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<!-- Tailwind CSS with CDN fallback --> <!-- Tailwind CSS v4 (built locally via standalone CLI) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
<!-- Admin-specific Tailwind customizations -->
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" /> <link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
<!-- Alpine Cloak --> <!-- Alpine Cloak -->

View File

@@ -1,6 +1,6 @@
{# app/templates/admin/login.html #} {# app/templates/admin/login.html #}
<!DOCTYPE html> <!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="adminLogin()" lang="en"> <html :class="{ 'dark': dark }" x-data="adminLogin()" lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />

View File

@@ -1,4 +1,5 @@
<DOCTYPE html> {# standalone - Minimal monitoring page without admin chrome #}
<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">

View File

@@ -1,519 +1,253 @@
<!DOCTYPE html> {# app/templates/admin/test-auth-flow.html #}
<html lang="en"> {% extends 'admin/base.html' %}
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auth Flow Testing - Admin Panel</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body { {% block title %}Auth Flow Testing{% endblock %}
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
padding: 20px;
background: #f5f5f5;
line-height: 1.6;
}
.container { {% block content %}
max-width: 1200px; <div x-data="authFlowTest()" x-init="init()">
margin: 0 auto; {# Page Header #}
background: white; <div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8">
padding: 30px; <div>
border-radius: 8px; <h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
box-shadow: 0 2px 8px rgba(0,0,0,0.1); Auth Flow Testing
} </h2>
<p class="text-gray-600 dark:text-gray-400 mt-1">
h1 { Comprehensive testing for Jinja2 migration auth loop fix
color: #333;
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 14px;
}
.test-section {
margin-bottom: 30px;
padding: 20px;
background: #f9f9f9;
border-radius: 6px;
border-left: 4px solid #3b82f6;
}
.test-section h2 {
color: #333;
margin-bottom: 15px;
font-size: 20px;
}
.test-description {
color: #666;
margin-bottom: 15px;
font-size: 14px;
}
.test-steps {
background: white;
padding: 15px;
border-radius: 4px;
margin-bottom: 15px;
}
.test-steps ol {
margin-left: 20px;
}
.test-steps li {
margin-bottom: 8px;
color: #444;
}
.expected-result {
background: #e8f5e9;
padding: 12px;
border-radius: 4px;
border-left: 3px solid #4caf50;
margin-bottom: 15px;
}
.expected-result strong {
color: #2e7d32;
display: block;
margin-bottom: 5px;
}
.expected-result ul {
margin-left: 20px;
color: #555;
}
.button-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
button:active {
transform: translateY(0);
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-warning {
background: #f59e0b;
color: white;
}
.btn-warning:hover {
background: #d97706;
}
.btn-success {
background: #10b981;
color: white;
}
.btn-success:hover {
background: #059669;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-secondary:hover {
background: #4b5563;
}
.status-panel {
background: #1e293b;
color: #e2e8f0;
padding: 20px;
border-radius: 6px;
margin-top: 30px;
font-family: 'Courier New', monospace;
font-size: 13px;
}
.status-panel h3 {
color: #38bdf8;
margin-bottom: 15px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.status-item {
margin-bottom: 8px;
display: flex;
justify-content: space-between;
}
.status-label {
color: #94a3b8;
}
.status-value {
color: #34d399;
font-weight: 500;
}
.status-value.false {
color: #f87171;
}
.log-level-control {
background: #fef3c7;
padding: 15px;
border-radius: 6px;
margin-bottom: 30px;
border-left: 4px solid #f59e0b;
}
.log-level-control h3 {
color: #92400e;
margin-bottom: 10px;
font-size: 16px;
}
.log-level-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.log-level-buttons button {
padding: 8px 16px;
font-size: 12px;
}
.warning-box {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 6px;
padding: 15px;
margin-top: 30px;
}
.warning-box h3 {
color: #991b1b;
margin-bottom: 10px;
font-size: 16px;
}
.warning-box ul {
margin-left: 20px;
color: #7f1d1d;
}
.warning-box li {
margin-bottom: 5px;
}
</style>
</head>
<body>
<div class="container">
<h1>🧪 Auth Flow Testing</h1>
<p class="subtitle">Comprehensive testing for the Jinja2 migration auth loop fix</p>
<!-- Log Level Control -->
<div class="log-level-control">
<h3>📊 Log Level Control</h3>
<p style="color: #78350f; font-size: 13px; margin-bottom: 10px;">
Change logging verbosity for login.js and api-client.js
</p> </p>
<div class="log-level-buttons">
<button onclick="setLogLevel(0)" class="btn-secondary">0 - None</button>
<button onclick="setLogLevel(1)" class="btn-danger">1 - Errors Only</button>
<button onclick="setLogLevel(2)" class="btn-warning">2 - Warnings</button>
<button onclick="setLogLevel(3)" class="btn-success">3 - Info (Production)</button>
<button onclick="setLogLevel(4)" class="btn-primary">4 - Debug (Full)</button>
</div>
<p style="color: #78350f; font-size: 12px; margin-top: 10px; font-style: italic;">
Current levels: LOGIN = <span id="currentLoginLevel">4</span>, API = <span id="currentApiLevel">3</span>
</p>
</div>
<!-- Test 1: Clean Slate -->
<div class="test-section">
<h2>Test 1: Clean Slate - Fresh Login Flow</h2>
<p class="test-description">
Tests the complete login flow from scratch with no existing tokens.
</p>
<div class="test-steps">
<strong>Steps:</strong>
<ol>
<li>Click "Clear All Data" below</li>
<li>Click "Navigate to /admin"</li>
<li>Observe browser behavior and console logs</li>
<li>You should land on login page</li>
</ol>
</div>
<div class="expected-result">
<strong>✅ Expected Result:</strong>
<ul>
<li>Single redirect: /admin → /admin/login</li>
<li>Login page loads with NO API calls to /admin/auth/me</li>
<li>No loops, no errors in console</li>
<li>Form is ready for input</li>
</ul>
</div>
<div class="button-group">
<button onclick="clearAllData()" class="btn-danger">Clear All Data</button>
<button onclick="navigateToAdmin()" class="btn-primary">Navigate to /admin</button>
<button onclick="navigateToLogin()" class="btn-secondary">Go to Login</button>
</div>
</div>
<!-- Test 2: Login Success -->
<div class="test-section">
<h2>Test 2: Successful Login</h2>
<p class="test-description">
Tests that login works correctly and redirects to dashboard.
</p>
<div class="test-steps">
<strong>Steps:</strong>
<ol>
<li>Ensure you're on /admin/login</li>
<li>Enter valid admin credentials</li>
<li>Click "Login"</li>
<li>Observe redirect and dashboard load</li>
</ol>
</div>
<div class="expected-result">
<strong>✅ Expected Result:</strong>
<ul>
<li>Login API call succeeds (check Network tab)</li>
<li>Token stored in localStorage</li>
<li>Success message shows briefly</li>
<li>Redirect to /admin/dashboard after 500ms</li>
<li>Dashboard loads with stats and recent vendors</li>
</ul>
</div>
<div class="button-group">
<button onclick="navigateToLogin()" class="btn-primary">Go to Login Page</button>
<button onclick="checkAuthStatus()" class="btn-secondary">Check Auth Status</button>
</div>
</div>
<!-- Test 3: Dashboard Refresh -->
<div class="test-section">
<h2>Test 3: Dashboard Refresh (Authenticated)</h2>
<p class="test-description">
Tests that refreshing the dashboard works without redirect loops.
</p>
<div class="test-steps">
<strong>Steps:</strong>
<ol>
<li>Complete Test 2 (login successfully)</li>
<li>On dashboard, press F5 or click "Refresh Page"</li>
<li>Observe page reload behavior</li>
</ol>
</div>
<div class="expected-result">
<strong>✅ Expected Result:</strong>
<ul>
<li>Dashboard reloads normally</li>
<li>No redirects to login</li>
<li>Stats and vendors load correctly</li>
<li>No console errors</li>
</ul>
</div>
<div class="button-group">
<button onclick="navigateToDashboard()" class="btn-primary">Go to Dashboard</button>
<button onclick="window.location.reload()" class="btn-secondary">Refresh Page</button>
</div>
</div>
<!-- Test 4: Expired Token -->
<div class="test-section">
<h2>Test 4: Expired Token Handling</h2>
<p class="test-description">
Tests that expired tokens are handled gracefully with redirect to login.
</p>
<div class="test-steps">
<strong>Steps:</strong>
<ol>
<li>Click "Set Expired Token"</li>
<li>Click "Navigate to Dashboard"</li>
<li>Observe authentication failure and redirect</li>
</ol>
</div>
<div class="expected-result">
<strong>✅ Expected Result:</strong>
<ul>
<li>Server detects expired token</li>
<li>Returns 401 Unauthorized</li>
<li>Browser redirects to /admin/login</li>
<li>Token is cleared from localStorage</li>
<li>No infinite loops</li>
</ul>
</div>
<div class="button-group">
<button onclick="setExpiredToken()" class="btn-warning">Set Expired Token</button>
<button onclick="navigateToDashboard()" class="btn-primary">Navigate to Dashboard</button>
</div>
</div>
<!-- Test 5: Direct Dashboard Access (No Token) -->
<div class="test-section">
<h2>Test 5: Direct Dashboard Access (Unauthenticated)</h2>
<p class="test-description">
Tests that accessing dashboard without token redirects to login.
</p>
<div class="test-steps">
<strong>Steps:</strong>
<ol>
<li>Click "Clear All Data"</li>
<li>Click "Navigate to Dashboard"</li>
<li>Observe immediate redirect to login</li>
</ol>
</div>
<div class="expected-result">
<strong>✅ Expected Result:</strong>
<ul>
<li>Redirect from /admin/dashboard to /admin/login</li>
<li>No API calls attempted</li>
<li>Login page loads correctly</li>
</ul>
</div>
<div class="button-group">
<button onclick="clearAllData()" class="btn-danger">Clear All Data</button>
<button onclick="navigateToDashboard()" class="btn-primary">Navigate to Dashboard</button>
</div>
</div>
<!-- Test 6: Login Page with Valid Token -->
<div class="test-section">
<h2>Test 6: Login Page with Valid Token</h2>
<p class="test-description">
Tests what happens when user visits login page while already authenticated.
</p>
<div class="test-steps">
<strong>Steps:</strong>
<ol>
<li>Login successfully (Test 2)</li>
<li>Click "Go to Login Page" below</li>
<li>Observe behavior</li>
</ol>
</div>
<div class="expected-result">
<strong>✅ Expected Result:</strong>
<ul>
<li>Login page loads</li>
<li>Existing token is cleared (init() clears it)</li>
<li>Form is displayed normally</li>
<li>NO redirect loops</li>
<li>NO API calls to validate token</li>
</ul>
</div>
<div class="button-group">
<button onclick="setValidToken()" class="btn-success">Set Valid Token (Mock)</button>
<button onclick="navigateToLogin()" class="btn-primary">Go to Login Page</button>
</div>
</div>
<!-- Status Panel -->
<div class="status-panel">
<h3>🔍 Current Auth Status</h3>
<div id="statusDisplay">
<div class="status-item">
<span class="status-label">Current URL:</span>
<span class="status-value" id="currentUrl">-</span>
</div>
<div class="status-item">
<span class="status-label">Has admin_token:</span>
<span class="status-value" id="hasToken">-</span>
</div>
<div class="status-item">
<span class="status-label">Has admin_user:</span>
<span class="status-value" id="hasUser">-</span>
</div>
<div class="status-item">
<span class="status-label">Token Preview:</span>
<span class="status-value" id="tokenPreview">-</span>
</div>
<div class="status-item">
<span class="status-label">Username:</span>
<span class="status-value" id="username">-</span>
</div>
</div>
<button onclick="updateStatus()" style="margin-top: 15px; background: #38bdf8; color: #0f172a; padding: 8px 16px; border-radius: 4px; font-size: 12px; cursor: pointer; border: none;">
🔄 Refresh Status
</button>
</div>
<!-- Warning Box -->
<div class="warning-box">
<h3>⚠️ Important Notes</h3>
<ul>
<li>Always check browser console for detailed logs</li>
<li>Use Network tab to see actual HTTP requests and redirects</li>
<li>Clear browser cache if you see unexpected behavior</li>
<li>Make sure FastAPI server is running on localhost:8000</li>
<li>Valid admin credentials required for login tests</li>
</ul>
</div> </div>
</div> </div>
<script> {# Log Level Control #}
// Update status display <div class="px-4 py-3 mb-6 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg shadow-md border-l-4 border-yellow-500">
function updateStatus() { <h4 class="mb-2 text-lg font-semibold text-yellow-800 dark:text-yellow-200">Log Level Control</h4>
<p class="text-sm text-yellow-700 dark:text-yellow-300 mb-3">
Change logging verbosity for login.js and api-client.js
</p>
<div class="flex flex-wrap gap-2">
<button @click="setLogLevel(0)" class="px-3 py-1 text-xs font-medium text-white bg-gray-600 rounded hover:bg-gray-700">0 - None</button>
<button @click="setLogLevel(1)" class="px-3 py-1 text-xs font-medium text-white bg-red-600 rounded hover:bg-red-700">1 - Errors</button>
<button @click="setLogLevel(2)" class="px-3 py-1 text-xs font-medium text-white bg-yellow-600 rounded hover:bg-yellow-700">2 - Warnings</button>
<button @click="setLogLevel(3)" class="px-3 py-1 text-xs font-medium text-white bg-green-600 rounded hover:bg-green-700">3 - Info</button>
<button @click="setLogLevel(4)" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">4 - Debug</button>
</div>
<p class="text-xs text-yellow-600 dark:text-yellow-400 mt-2 italic">
Current: LOGIN = <span x-text="currentLoginLevel">4</span>, API = <span x-text="currentApiLevel">3</span>
</p>
</div>
{# Test Sections Grid #}
<div class="grid gap-6 mb-8 md:grid-cols-2">
{# Test 1: Clean Slate #}
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-blue-500">
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
Test 1: Clean Slate - Fresh Login
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
Tests complete login flow from scratch with no existing tokens.
</p>
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
<li>Clear All Data</li>
<li>Navigate to /admin</li>
<li>Should land on login page</li>
</ol>
</div>
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
<p class="text-xs text-green-700 dark:text-green-400">Expected: Single redirect /admin -> /admin/login, no loops</p>
</div>
<div class="flex flex-wrap gap-2">
<button @click="clearAllData()" class="px-3 py-1 text-xs font-medium text-white bg-red-600 rounded hover:bg-red-700">Clear All Data</button>
<button @click="navigateTo('/admin')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to /admin</button>
</div>
</div>
{# Test 2: Successful Login #}
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-green-500">
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
Test 2: Successful Login
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
Tests that login works correctly and redirects to dashboard.
</p>
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
<li>Go to /admin/login</li>
<li>Enter valid admin credentials</li>
<li>Click Login</li>
</ol>
</div>
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
<p class="text-xs text-green-700 dark:text-green-400">Expected: Token stored, redirect to /admin/dashboard</p>
</div>
<div class="flex flex-wrap gap-2">
<button @click="navigateTo('/admin/login')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to Login</button>
<button @click="checkAuthStatus()" class="px-3 py-1 text-xs font-medium text-white bg-gray-600 rounded hover:bg-gray-700">Check Status</button>
</div>
</div>
{# Test 3: Dashboard Refresh #}
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-purple-500">
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
Test 3: Dashboard Refresh
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
Tests that refreshing dashboard works without redirect loops.
</p>
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
<li>Complete Test 2 (login)</li>
<li>Press F5 or click Refresh</li>
<li>Dashboard should reload normally</li>
</ol>
</div>
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
<p class="text-xs text-green-700 dark:text-green-400">Expected: No redirect to login, stats load correctly</p>
</div>
<div class="flex flex-wrap gap-2">
<button @click="navigateTo('/admin/dashboard')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to Dashboard</button>
<button @click="window.location.reload()" class="px-3 py-1 text-xs font-medium text-white bg-gray-600 rounded hover:bg-gray-700">Refresh Page</button>
</div>
</div>
{# Test 4: Expired Token #}
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-orange-500">
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
Test 4: Expired Token Handling
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
Tests that expired tokens are handled gracefully.
</p>
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
<li>Set Expired Token</li>
<li>Navigate to Dashboard</li>
<li>Should redirect to login</li>
</ol>
</div>
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
<p class="text-xs text-green-700 dark:text-green-400">Expected: 401 response, redirect to login, no loops</p>
</div>
<div class="flex flex-wrap gap-2">
<button @click="setExpiredToken()" class="px-3 py-1 text-xs font-medium text-white bg-orange-600 rounded hover:bg-orange-700">Set Expired Token</button>
<button @click="navigateTo('/admin/dashboard')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to Dashboard</button>
</div>
</div>
{# Test 5: Direct Access (No Token) #}
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-red-500">
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
Test 5: Direct Access (Unauthenticated)
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
Tests accessing dashboard without token redirects to login.
</p>
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
<li>Clear All Data</li>
<li>Navigate to Dashboard</li>
<li>Should redirect to login</li>
</ol>
</div>
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
<p class="text-xs text-green-700 dark:text-green-400">Expected: Redirect to /admin/login, no API calls</p>
</div>
<div class="flex flex-wrap gap-2">
<button @click="clearAllData()" class="px-3 py-1 text-xs font-medium text-white bg-red-600 rounded hover:bg-red-700">Clear All Data</button>
<button @click="navigateTo('/admin/dashboard')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to Dashboard</button>
</div>
</div>
{# Test 6: Login with Valid Token #}
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800 border-l-4 border-teal-500">
<h4 class="mb-2 text-lg font-semibold text-gray-600 dark:text-gray-300">
Test 6: Login Page with Valid Token
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
Tests visiting login page while already authenticated.
</p>
<div class="p-3 mb-3 bg-gray-50 dark:bg-gray-700 rounded text-sm">
<ol class="list-decimal list-inside text-gray-700 dark:text-gray-300 space-y-1">
<li>Login successfully (Test 2)</li>
<li>Click Go to Login Page</li>
<li>Token should be cleared</li>
</ol>
</div>
<div class="p-2 mb-3 bg-green-50 dark:bg-green-900/20 rounded border-l-3 border-green-500">
<p class="text-xs text-green-700 dark:text-green-400">Expected: Token cleared, form displayed, no loops</p>
</div>
<div class="flex flex-wrap gap-2">
<button @click="setMockToken()" class="px-3 py-1 text-xs font-medium text-white bg-green-600 rounded hover:bg-green-700">Set Mock Token</button>
<button @click="navigateTo('/admin/login')" class="px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded hover:bg-blue-700">Go to Login</button>
</div>
</div>
</div>
{# Status Panel #}
<div class="px-4 py-3 bg-gray-800 rounded-lg shadow-md">
<div class="flex items-center justify-between mb-3">
<h4 class="text-lg font-semibold text-gray-200">Current Auth Status</h4>
<button @click="updateStatus()" class="px-3 py-1 text-xs text-gray-400 border border-gray-600 rounded hover:bg-gray-700">Refresh</button>
</div>
<div class="font-mono text-sm space-y-2">
<div class="flex justify-between">
<span class="text-gray-500">Current URL:</span>
<span class="text-blue-400" x-text="currentUrl">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Has admin_token:</span>
<span :class="hasToken ? 'text-green-400' : 'text-red-400'" x-text="hasToken ? 'Yes' : 'No'">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Has admin_user:</span>
<span :class="hasUser ? 'text-green-400' : 'text-red-400'" x-text="hasUser ? 'Yes' : 'No'">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Token Preview:</span>
<span class="text-green-400 truncate max-w-xs" x-text="tokenPreview">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Username:</span>
<span class="text-green-400" x-text="username">-</span>
</div>
</div>
</div>
{# Warning Box #}
<div class="mt-6 px-4 py-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
<h4 class="text-lg font-semibold text-red-700 dark:text-red-300 mb-2">Important Notes</h4>
<ul class="list-disc list-inside text-sm text-red-600 dark:text-red-400 space-y-1">
<li>Always check browser console for detailed logs</li>
<li>Use Network tab to see actual HTTP requests and redirects</li>
<li>Clear browser cache if you see unexpected behavior</li>
<li>Make sure FastAPI server is running on localhost:8000</li>
<li>Valid admin credentials required for login tests</li>
</ul>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function authFlowTest() {
return {
...data(),
currentPage: 'auth-testing',
currentUrl: '-',
hasToken: false,
hasUser: false,
tokenPreview: '-',
username: '-',
currentLoginLevel: 4,
currentApiLevel: 3,
init() {
this.updateStatus();
setInterval(() => this.updateStatus(), 2000);
console.log('Auth Flow Testing Script Loaded');
},
updateStatus() {
const token = localStorage.getItem('admin_token'); const token = localStorage.getItem('admin_token');
const userStr = localStorage.getItem('admin_user'); const userStr = localStorage.getItem('admin_user');
let user = null; let user = null;
@@ -524,121 +258,67 @@
console.error('Failed to parse user data:', e); console.error('Failed to parse user data:', e);
} }
document.getElementById('currentUrl').textContent = window.location.href; this.currentUrl = window.location.href;
this.hasToken = !!token;
this.hasUser = !!user;
this.tokenPreview = token ? token.substring(0, 30) + '...' : 'No token';
this.username = user?.username || 'Not logged in';
},
const hasTokenEl = document.getElementById('hasToken'); clearAllData() {
hasTokenEl.textContent = token ? 'Yes' : 'No'; console.log('Clearing all localStorage data...');
hasTokenEl.className = token ? 'status-value' : 'status-value false';
const hasUserEl = document.getElementById('hasUser');
hasUserEl.textContent = user ? 'Yes' : 'No';
hasUserEl.className = user ? 'status-value' : 'status-value false';
document.getElementById('tokenPreview').textContent = token
? token.substring(0, 30) + '...'
: 'No token';
document.getElementById('username').textContent = user?.username || 'Not logged in';
console.log('📊 Status Updated:', {
hasToken: !!token,
hasUser: !!user,
user: user
});
}
// Test functions
function clearAllData() {
console.log('🗑️ Clearing all localStorage data...');
localStorage.clear(); localStorage.clear();
console.log('All data cleared'); console.log('All data cleared');
alert('All localStorage data cleared!\n\nCheck console for details.'); alert('All localStorage data cleared!');
updateStatus(); this.updateStatus();
} },
function navigateToAdmin() { navigateTo(path) {
console.log('🚀 Navigating to /admin...'); console.log(`Navigating to ${path}...`);
window.location.href = '/admin'; window.location.href = path;
} },
function navigateToLogin() { checkAuthStatus() {
console.log('🚀 Navigating to /admin/login...'); this.updateStatus();
window.location.href = '/admin/login';
}
function navigateToDashboard() {
console.log('🚀 Navigating to /admin/dashboard...');
window.location.href = '/admin/dashboard';
}
function checkAuthStatus() {
updateStatus();
alert('Check console and status panel for auth details.'); alert('Check console and status panel for auth details.');
} },
function setExpiredToken() { setExpiredToken() {
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyMzkwMjJ9.invalidexpiredtoken'; const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNTE2MjM5MDIyfQ.invalid';
console.log('⚠️ Setting expired/invalid token...');
localStorage.setItem('admin_token', expiredToken); localStorage.setItem('admin_token', expiredToken);
localStorage.setItem('admin_user', JSON.stringify({ localStorage.setItem('admin_user', JSON.stringify({
id: 1, id: 1,
username: 'test_expired', username: 'test_expired',
role: 'admin' role: 'admin'
})); }));
console.log('Expired token set'); alert('Expired token set! Now try navigating to dashboard.');
alert('⚠️ Expired token set!\n\nNow try navigating to dashboard.'); this.updateStatus();
updateStatus(); },
}
function setValidToken() { setMockToken() {
// This is a mock token - won't actually work with backend
const mockToken = 'mock_valid_token_' + Date.now(); const mockToken = 'mock_valid_token_' + Date.now();
console.log('✅ Setting mock valid token...');
localStorage.setItem('admin_token', mockToken); localStorage.setItem('admin_token', mockToken);
localStorage.setItem('admin_user', JSON.stringify({ localStorage.setItem('admin_user', JSON.stringify({
id: 1, id: 1,
username: 'test_user', username: 'test_user',
role: 'admin' role: 'admin'
})); }));
console.log('Mock token set (will not work with real backend)'); alert('Mock token set! Note: This won\'t work with real backend.');
alert('✅ Mock token set!\n\nNote: This is a fake token and won\'t work with the real backend.'); this.updateStatus();
updateStatus(); },
}
// Log level control setLogLevel(level) {
function setLogLevel(level) { if (typeof window.LOG_LEVEL !== 'undefined') {
console.log(`📊 Setting log level to ${level}...`);
// Note: This only works if login.js and api-client.js are loaded
// In production, you'd need to reload the page or use a more sophisticated approach
if (typeof LOG_LEVEL !== 'undefined') {
window.LOG_LEVEL = level; window.LOG_LEVEL = level;
document.getElementById('currentLoginLevel').textContent = level; this.currentLoginLevel = level;
console.log('✅ LOGIN log level set to', level);
} else {
console.warn('⚠️ LOG_LEVEL not found (login.js not loaded)');
} }
if (typeof window.API_LOG_LEVEL !== 'undefined') {
if (typeof API_LOG_LEVEL !== 'undefined') {
window.API_LOG_LEVEL = level; window.API_LOG_LEVEL = level;
document.getElementById('currentApiLevel').textContent = level; this.currentApiLevel = level;
console.log('✅ API log level set to', level);
} else {
console.warn('⚠️ API_LOG_LEVEL not found (api-client.js not loaded)');
} }
alert(`Log level set to ${level}. Reload to apply to all scripts.`);
alert(`Log level set to ${level}\n\n0 = None\n1 = Errors\n2 = Warnings\n3 = Info\n4 = Debug\n\nNote: Changes apply to current page. Reload to apply to all scripts.`);
} }
};
// Initialize status on load }
updateStatus(); </script>
{% endblock %}
// Auto-refresh status every 2 seconds
setInterval(updateStatus, 2000);
console.log('🧪 Auth Flow Testing Script Loaded');
console.log('📊 Use the buttons above to run tests');
console.log('🔍 Watch browser console and Network tab for details');
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -41,51 +41,8 @@
<link href="/static/shared/fonts/inter.css" rel="stylesheet" /> <link href="/static/shared/fonts/inter.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
{# Tailwind CSS with local fallback #} {# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" <link rel="stylesheet" href="{{ url_for('static', path='platform/css/tailwind.output.css') }}">
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
{# Platform-specific styles #}
<style>
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Custom gradients */
.gradient-primary {
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%);
}
.gradient-accent {
background: linear-gradient(135deg, var(--color-secondary) 0%, var(--color-accent) 100%);
}
/* Button styles */
.btn-primary {
background-color: var(--color-primary);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
transition: all 0.2s;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* Card hover effect */
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
</style>
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>

View File

@@ -1,28 +0,0 @@
{# app/templates/shared/cdn-fallback.html #}
{# CDN with Local Fallback Pattern #}
{# This partial handles loading CDN resources with automatic fallback to local copies #}
{# Tailwind CSS with fallback #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
{# Alpine.js with fallback - must be loaded at the end of body #}
{# Usage: Include this partial at the bottom of your template, before page-specific scripts #}
<script>
// Alpine.js CDN with fallback
(function() {
var script = document.createElement('script');
script.defer = true;
script.src = 'https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js';
script.onerror = function() {
console.warn('Alpine.js CDN failed, loading local copy...');
var fallbackScript = document.createElement('script');
fallbackScript.defer = true;
fallbackScript.src = '{{ url_for("static", path="shared/js/vendor/alpine.min.js") }}';
document.head.appendChild(fallbackScript);
};
document.head.appendChild(script);
})();
</script>

View File

@@ -1,11 +1,15 @@
<DOCTYPE html> {# app/templates/shop/account/addresses.html #}
<html lang="en"> {% extends "shop/base.html" %}
<head>
<meta charset="UTF-8"> {% block title %}My Addresses{% endblock %}
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Address management</title> {% block content %}
</head> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<body> <h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">My Addresses</h1>
<-- Address management -->
</body> {# TODO: Implement address management #}
</html> <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-gray-600 dark:text-gray-400">Address management coming soon...</p>
</div>
</div>
{% endblock %}

View File

@@ -1,6 +1,6 @@
{# app/templates/shop/account/forgot-password.html #} {# app/templates/shop/account/forgot-password.html #}
<!DOCTYPE html> <!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="forgotPassword()" lang="en"> <html :class="{ 'dark': dark }" x-data="forgotPassword()" lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -37,9 +37,8 @@
[x-cloak] { display: none !important; } [x-cloak] { display: none !important; }
</style> </style>
{# Tailwind CSS with local fallback #} {# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" <link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
</head> </head>
<body> <body>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak> <div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>

View File

@@ -1,6 +1,6 @@
{# app/templates/shop/account/login.html #} {# app/templates/shop/account/login.html #}
<!DOCTYPE html> <!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="customerLogin()" lang="en"> <html :class="{ 'dark': dark }" x-data="customerLogin()" lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -37,9 +37,8 @@
[x-cloak] { display: none !important; } [x-cloak] { display: none !important; }
</style> </style>
{# Tailwind CSS with local fallback #} {# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" <link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
</head> </head>
<body> <body>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak> <div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>

View File

@@ -1,11 +1,15 @@
<DOCTYPE html> {# app/templates/shop/account/orders.html #}
<html lang="en"> {% extends "shop/base.html" %}
<head>
<meta charset="UTF-8"> {% block title %}Order History{% endblock %}
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Order history</title> {% block content %}
</head> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<body> <h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">Order History</h1>
<-- Order history -->
</body> {# TODO: Implement order history #}
</html> <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-gray-600 dark:text-gray-400">Order history coming soon...</p>
</div>
</div>
{% endblock %}

View File

@@ -1,11 +1,15 @@
<DOCTYPE html> {# app/templates/shop/account/profile.html #}
<html lang="en"> {% extends "shop/base.html" %}
<head>
<meta charset="UTF-8"> {% block title %}My Profile{% endblock %}
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Customer profile</title> {% block content %}
</head> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<body> <h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">My Profile</h1>
<-- Customer profile -->
</body> {# TODO: Implement profile management #}
</html> <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-gray-600 dark:text-gray-400">Profile management coming soon...</p>
</div>
</div>
{% endblock %}

View File

@@ -1,6 +1,6 @@
{# app/templates/shop/account/register.html #} {# app/templates/shop/account/register.html #}
<!DOCTYPE html> <!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="customerRegistration()" lang="en"> <html :class="{ 'dark': dark }" x-data="customerRegistration()" lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -37,9 +37,8 @@
[x-cloak] { display: none !important; } [x-cloak] { display: none !important; }
</style> </style>
{# Tailwind CSS with local fallback #} {# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" <link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
</head> </head>
<body> <body>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak> <div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>

View File

@@ -37,9 +37,8 @@
{% endif %} {% endif %}
</style> </style>
{# Tailwind CSS with local fallback #} {# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" <link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
{# Base Shop Styles #} {# Base Shop Styles #}
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/shop.css') }}"> <link rel="stylesheet" href="{{ url_for('static', path='shop/css/shop.css') }}">

View File

@@ -1,11 +1,15 @@
<DOCTYPE html> {# app/templates/shop/checkout.html #}
<html lang="en"> {% extends "shop/base.html" %}
<head>
<meta charset="UTF-8"> {% block title %}Checkout{% endblock %}
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Checkout process</title> {% block content %}
</head> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<body> <h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">Checkout</h1>
<-- Checkout process -->
</body> {# TODO: Implement checkout process #}
</html> <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-gray-600 dark:text-gray-400">Checkout process coming soon...</p>
</div>
</div>
{% endblock %}

View File

@@ -1,191 +1,104 @@
{# app/templates/shop/errors/base.html #}
{# Error page base template using Tailwind CSS with vendor theme support #}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" class="h-full">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ status_code }} - {{ status_name }}{% endblock %}{% if vendor %} | {{ vendor.name }}{% endif %}</title> <title>{% block title %}{{ status_code }} - {{ status_name }}{% endblock %}{% if vendor %} | {{ vendor.name }}{% endif %}</title>
{# Tailwind CSS #}
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}">
{# Vendor theme colors via CSS variables #}
<style> <style>
:root { :root {
/* Default theme colors (fallback) */
--color-primary: {{ theme.colors.primary if theme and theme.colors else '#6366f1' }}; --color-primary: {{ theme.colors.primary if theme and theme.colors else '#6366f1' }};
--color-secondary: {{ theme.colors.secondary if theme and theme.colors else '#8b5cf6' }}; --color-secondary: {{ theme.colors.secondary if theme and theme.colors else '#8b5cf6' }};
--color-accent: {{ theme.colors.accent if theme and theme.colors else '#ec4899' }}; --color-accent: {{ theme.colors.accent if theme and theme.colors else '#ec4899' }};
--color-background: {{ theme.colors.background if theme and theme.colors else '#ffffff' }};
--color-text: {{ theme.colors.text if theme and theme.colors else '#1f2937' }};
--color-border: {{ theme.colors.border if theme and theme.colors else '#e5e7eb' }};
--font-heading: {{ theme.fonts.heading if theme and theme.fonts else "'Inter', sans-serif" }};
--font-body: {{ theme.fonts.body if theme and theme.fonts else "'Inter', sans-serif" }};
} }
* { .bg-gradient-theme {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-body);
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%); background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text);
padding: 2rem;
} }
.error-container { .text-theme-primary {
background: var(--color-background);
border-radius: 1.5rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
max-width: 600px;
width: 100%;
padding: 3rem;
text-align: center;
}
{% if vendor and vendor.logo %}
.vendor-logo {
max-width: 150px;
max-height: 60px;
margin-bottom: 2rem;
}
{% endif %}
.error-icon {
font-size: 5rem;
margin-bottom: 1rem;
}
.status-code {
font-size: 6rem;
font-weight: 700;
color: var(--color-primary); color: var(--color-primary);
line-height: 1;
margin-bottom: 0.5rem;
font-family: var(--font-heading);
} }
.status-name { .bg-theme-primary {
font-size: 1.75rem; background-color: var(--color-primary);
font-weight: 600;
color: var(--color-text);
margin-bottom: 1rem;
font-family: var(--font-heading);
} }
.error-message { .border-theme-primary {
font-size: 1.125rem; border-color: var(--color-primary);
color: #6b7280;
margin-bottom: 2.5rem;
line-height: 1.6;
} }
.action-buttons { .hover\:bg-theme-primary:hover {
display: flex; background-color: var(--color-primary);
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-top: 2rem;
}
.btn {
display: inline-flex;
align-items: center;
padding: 1rem 2rem;
border-radius: 0.75rem;
font-weight: 600;
text-decoration: none;
transition: all 0.3s ease;
border: none;
cursor: pointer;
font-size: 1rem;
}
.btn-primary {
background: var(--color-primary);
color: white;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.btn-secondary {
background: transparent;
color: var(--color-primary);
border: 2px solid var(--color-primary);
}
.btn-secondary:hover {
background: var(--color-primary);
color: white;
transform: translateY(-2px);
}
.support-link {
margin-top: 2.5rem;
padding-top: 2rem;
border-top: 1px solid var(--color-border);
font-size: 0.875rem;
color: #6b7280;
}
.support-link a {
color: var(--color-primary);
text-decoration: none;
font-weight: 600;
}
.support-link a:hover {
text-decoration: underline;
}
.vendor-info {
margin-top: 2rem;
font-size: 0.875rem;
color: #9ca3af;
} }
{% block extra_styles %}{% endblock %} {% block extra_styles %}{% endblock %}
</style> </style>
{% if theme and theme.custom_css %} {% if theme and theme.custom_css %}
<style> <style>{{ theme.custom_css | safe }}</style>
{{ theme.custom_css | safe }}
</style>
{% endif %} {% endif %}
</head> </head>
<body> <body class="h-full bg-gradient-theme flex items-center justify-center p-8">
<div class="error-container"> <div class="bg-white rounded-3xl shadow-2xl max-w-xl w-full p-12 text-center">
{# Vendor Logo #}
{% if vendor and theme and theme.branding and theme.branding.logo %} {% if vendor and theme and theme.branding and theme.branding.logo %}
<img src="{{ theme.branding.logo }}" alt="{{ vendor.name }}" class="vendor-logo"> <img src="{{ theme.branding.logo }}"
alt="{{ vendor.name }}"
class="max-w-[150px] max-h-[60px] mx-auto mb-8 object-contain">
{% endif %} {% endif %}
{% block content %} {% block content %}
<div class="error-icon">{% block icon %}⚠️{% endblock %}</div> {# Error Icon #}
<div class="status-code">{{ status_code }}</div> <div class="text-7xl mb-4">{% block icon %}⚠️{% endblock %}</div>
<div class="status-name">{{ status_name }}</div>
<div class="error-message">{{ message }}</div>
<div class="action-buttons"> {# Status Code #}
<div class="text-8xl font-bold text-theme-primary leading-none mb-2">
{{ status_code }}
</div>
{# Status Name #}
<h1 class="text-3xl font-semibold text-gray-900 mb-4">
{{ status_name }}
</h1>
{# Error Message #}
<p class="text-lg text-gray-500 mb-10 leading-relaxed">
{{ message }}
</p>
{# Action Buttons #}
<div class="flex gap-4 justify-center flex-wrap mt-8">
{% block action_buttons %} {% block action_buttons %}
<a href="{{ base_url }}shop/" class="btn btn-primary">Continue Shopping</a> <a href="{{ base_url }}shop/"
<a href="{{ base_url }}shop/contact" class="btn btn-secondary">Contact Us</a> class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-white bg-theme-primary hover:opacity-90 hover:-translate-y-0.5 transition-all shadow-lg">
Continue Shopping
</a>
<a href="{{ base_url }}shop/contact"
class="inline-flex items-center px-8 py-4 rounded-xl font-semibold text-theme-primary border-2 border-theme-primary hover:bg-theme-primary hover:text-white hover:-translate-y-0.5 transition-all">
Contact Us
</a>
{% endblock %} {% endblock %}
</div> </div>
{% block extra_content %}{% endblock %} {% block extra_content %}{% endblock %}
<div class="support-link"> {# Support Link #}
<div class="mt-10 pt-8 border-t border-gray-200 text-sm text-gray-500">
{% block support_link %} {% block support_link %}
Need help? <a href="{{ base_url }}shop/contact">Contact our support team</a> Need help? <a href="{{ base_url }}shop/contact" class="text-theme-primary font-semibold hover:underline">Contact our support team</a>
{% endblock %} {% endblock %}
</div> </div>
{# Vendor Info #}
{% if vendor %} {% if vendor %}
<div class="vendor-info"> <div class="mt-8 text-sm text-gray-400">
{{ vendor.name }} {{ vendor.name }}
</div> </div>
{% endif %} {% endif %}

View File

@@ -1,11 +1,15 @@
<DOCTYPE html> {# app/templates/shop/search.html #}
<html lang="en"> {% extends "shop/base.html" %}
<head>
<meta charset="UTF-8"> {% block title %}Search Results{% endblock %}
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Search results page</title> {% block content %}
</head> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<body> <h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">Search Results</h1>
<-- Search results page -->
</body> {# TODO: Implement search results #}
</html> <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-gray-600 dark:text-gray-400">Search results coming soon...</p>
</div>
</div>
{% endblock %}

View File

@@ -1,6 +1,6 @@
{# app/templates/vendor/base.html #} {# app/templates/vendor/base.html #}
<!DOCTYPE html> <!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en"> <html :class="{ 'dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -10,11 +10,7 @@
<link href="/static/shared/fonts/inter.css" rel="stylesheet" /> <link href="/static/shared/fonts/inter.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<!-- Tailwind CSS with CDN fallback --> <!-- Tailwind CSS v4 (built locally via standalone CLI) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
<!-- Vendor-specific Tailwind customizations -->
<link rel="stylesheet" href="{{ url_for('static', path='vendor/css/tailwind.output.css') }}" /> <link rel="stylesheet" href="{{ url_for('static', path='vendor/css/tailwind.output.css') }}" />
<!-- Alpine Cloak --> <!-- Alpine Cloak -->

View File

@@ -1,6 +1,6 @@
{# app/templates/vendor/login.html #} {# app/templates/vendor/login.html #}
<!DOCTYPE html> <!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="vendorLogin()" lang="en"> <html :class="{ 'dark': dark }" x-data="vendorLogin()" lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />

View File

@@ -355,18 +355,40 @@ def update_product(
return ProductResponse.model_validate(product) return ProductResponse.model_validate(product)
``` ```
## Files to Migrate ## Migration Status
Current files still using `require_vendor_context()`: **COMPLETED** - All vendor API endpoints have been migrated to use the token-based vendor context pattern.
- `app/api/v1/vendor/customers.py`
- `app/api/v1/vendor/notifications.py` ### Migrated Files
- `app/api/v1/vendor/media.py` All vendor API files now use `current_user.token_vendor_id`:
- `app/api/v1/vendor/marketplace.py` - `app/api/v1/vendor/customers.py`
- `app/api/v1/vendor/inventory.py` - `app/api/v1/vendor/notifications.py`
- `app/api/v1/vendor/settings.py` - `app/api/v1/vendor/media.py`
- `app/api/v1/vendor/analytics.py` - `app/api/v1/vendor/marketplace.py`
- `app/api/v1/vendor/payments.py` - `app/api/v1/vendor/inventory.py`
- `app/api/v1/vendor/profile.py` - `app/api/v1/vendor/settings.py`
- `app/api/v1/vendor/analytics.py`
- `app/api/v1/vendor/payments.py`
- `app/api/v1/vendor/profile.py`
- `app/api/v1/vendor/dashboard.py`
- `app/api/v1/vendor/products.py`
- `app/api/v1/vendor/orders.py`
- `app/api/v1/vendor/team.py` ✅ (uses permission dependencies)
### Permission Dependencies Updated
The following permission dependencies now use token-based vendor context:
- `require_vendor_permission()` - Gets vendor from token, sets `request.state.vendor`
- `require_vendor_owner` - Gets vendor from token, sets `request.state.vendor`
- `require_any_vendor_permission()` - Gets vendor from token, sets `request.state.vendor`
- `require_all_vendor_permissions()` - Gets vendor from token, sets `request.state.vendor`
- `get_user_permissions` - Gets vendor from token, sets `request.state.vendor`
### Shop Endpoints
Shop endpoints (public, no authentication) still use `require_vendor_context()`:
- `app/api/v1/shop/products.py` - Uses URL/subdomain/domain detection
- `app/api/v1/shop/cart.py` - Uses URL/subdomain/domain detection
This is correct behavior - shop endpoints need to detect vendor from the request URL, not from JWT token.
## Benefits of Vendor-in-Token ## Benefits of Vendor-in-Token
@@ -483,9 +505,9 @@ See `docs/architecture/rules/API-VND-001.md` for the formal architecture rule en
## Related Documentation ## Related Documentation
- [Vendor RBAC System](./vendor-rbac.md) - Role-based access control for vendors - [Vendor RBAC System](./vendor-rbac.md) - Role-based access control for vendors
- [Vendor Authentication](./vendor-authentication.md) - Complete authentication guide - [Authentication & RBAC](../architecture/auth-rbac.md) - Complete authentication guide
- [Architecture Rules](../architecture/rules/) - All architecture rules - [Architecture Patterns](../architecture/architecture-patterns.md) - All architecture patterns
- [API Design Guidelines](../architecture/api-design.md) - RESTful API patterns - [Middleware Reference](./middleware-reference.md) - Middleware patterns
## Summary ## Summary

View File

@@ -136,32 +136,36 @@ async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
- `db.commit()` - `db.commit()`
- `db.query(` - `db.query(`
#### API-003: Catch Service Exceptions #### API-003: No HTTPException in Endpoints
**Severity:** Error **Severity:** Error
API endpoints must catch domain exceptions from services and convert them to appropriate HTTPException with proper status codes. API endpoints must NOT raise HTTPException directly. Instead, let domain exceptions bubble up to the global exception handler which converts them to appropriate HTTP responses.
```python ```python
# ✅ Good # ✅ Good - Let domain exceptions bubble up
@router.post("/vendors") @router.post("/vendors")
async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)): async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
try: # Service raises VendorAlreadyExistsException if duplicate
result = vendor_service.create_vendor(db, vendor) # Global handler converts to 409 Conflict
return result return vendor_service.create_vendor(db, vendor)
except VendorAlreadyExistsError as e:
raise HTTPException(status_code=409, detail=str(e)) # ❌ Bad - Don't raise HTTPException directly
except Exception as e: @router.post("/vendors")
logger.error(f"Unexpected error: {e}") async def create_vendor(vendor: VendorCreate, db: Session = Depends(get_db)):
raise HTTPException(status_code=500, detail="Internal server error") if vendor_service.exists(db, vendor.subdomain):
raise HTTPException(status_code=409, detail="Vendor exists") # BAD!
return vendor_service.create_vendor(db, vendor)
``` ```
**Pattern:** Services raise domain exceptions → Global handler converts to HTTP responses
#### API-004: Proper Authentication #### API-004: Proper Authentication
**Severity:** Warning **Severity:** Warning
Protected endpoints must use Depends() for authentication. Protected endpoints must use Depends() for authentication.
```python ```python
# ✅ Good # ✅ Good - Protected endpoint with authentication
@router.post("/vendors") @router.post("/vendors")
async def create_vendor( async def create_vendor(
vendor: VendorCreate, vendor: VendorCreate,
@@ -171,6 +175,31 @@ async def create_vendor(
pass pass
``` ```
**Auto-Excluded Files:**
The validator automatically skips API-004 checks for authentication endpoint files (`*/auth.py`) since login, logout, and registration endpoints are intentionally public.
**Marking Public Endpoints:**
For other intentionally public endpoints (webhooks, health checks, etc.), use a comment marker:
```python
# ✅ Good - Webhook endpoint marked as public
# public - Stripe webhook receives external callbacks
@router.post("/webhook/stripe")
def stripe_webhook(request: Request):
...
# ✅ Good - Using noqa style
@router.post("/health") # noqa: API-004
def health_check():
...
```
**Recognized markers:**
- `# public` - descriptive marker for intentionally unauthenticated endpoints
- `# noqa: API-004` - standard noqa style to suppress the warning
#### API-005: Multi-Tenant Scoping #### API-005: Multi-Tenant Scoping
**Severity:** Error **Severity:** Error
@@ -542,12 +571,41 @@ async loadData() {
#### TPL-001: Admin Templates Extend admin/base.html #### TPL-001: Admin Templates Extend admin/base.html
**Severity:** Error **Severity:** Error
All admin templates must extend the base template for consistent layout (sidebar, navigation, etc.).
```jinja ```jinja
{% extends "admin/base.html" %} {# ✅ Good - Extends base template #}
❌ No extends directive {% extends "admin/base.html" %}
{% block content %}
...
{% endblock %}
``` ```
Exceptions: `base.html` itself, files in `partials/` **Auto-Excluded Files:**
The validator automatically skips TPL-001 checks for:
- `login.html` - Standalone login page (no sidebar/navigation needed)
- `errors/*.html` - Error pages extend `errors/base.html` instead
- `test-*.html` - Test/development templates
- `base.html` - The base template itself
- `partials/*.html` - Partial templates included in other templates
**Marking Standalone Templates:**
For other templates that intentionally don't extend base.html, use a comment marker in the first 5 lines:
```jinja
{# standalone - Minimal monitoring page without admin chrome #}
<!DOCTYPE html>
<html>
...
```
**Recognized markers:**
- `{# standalone #}` - Jinja comment style
- `{# noqa: TPL-001 #}` - Standard noqa style
- `<!-- standalone -->` - HTML comment style
#### TPL-002: Vendor Templates Extend vendor/base.html #### TPL-002: Vendor Templates Extend vendor/base.html
**Severity:** Error **Severity:** Error
@@ -789,12 +847,13 @@ Before committing code:
| Violation | Quick Fix | | Violation | Quick Fix |
|-----------|-----------| |-----------|-----------|
| HTTPException in service | Create custom exception in `app/exceptions/` | | HTTPException in service | Create custom exception in `app/exceptions/` |
| HTTPException in endpoint | Let domain exceptions bubble up to global handler |
| Business logic in endpoint | Move to service layer | | Business logic in endpoint | Move to service layer |
| No exception handling | Add try/except, convert to HTTPException |
| console.log in JS | Use `window.LogConfig.createLogger()` | | console.log in JS | Use `window.LogConfig.createLogger()` |
| Missing ...data() | Add spread operator in component return | | Missing ...data() | Add spread operator in component return |
| Bare except clause | Specify exception type | | Bare except clause | Specify exception type |
| Raw dict return | Create Pydantic response model | | Raw dict return | Create Pydantic response model |
| Template not extending base | Add `{% extends %}` or `{# standalone #}` marker |
--- ---
@@ -834,5 +893,5 @@ All rules are defined in `.architecture-rules.yaml`. To modify rules:
--- ---
**Last Updated:** 2025-11-28 **Last Updated:** 2025-12-04
**Version:** 2.0 **Version:** 2.2

View File

@@ -324,5 +324,5 @@ Migration failures will halt deployment to prevent data corruption.
## Further Reading ## Further Reading
- [Alembic Official Documentation](https://alembic.sqlalchemy.org/) - [Alembic Official Documentation](https://alembic.sqlalchemy.org/)
- [Database Setup Guide](../getting-started/database-setup-guide.md) - [Database Seeder Documentation](../database-seeder/database-seeder-documentation.md)
- [Deployment Guide](../deployment/production.md) - [Database Init Guide](../database-seeder/database-init-guide.md)

View File

@@ -0,0 +1,402 @@
# Tailwind CSS Migration Plan: v1.4/v2.2 → v4.0 (Standalone CLI)
**Created:** December 2024
**Completed:** December 2024
**Status:** COMPLETE
**Goal:** Eliminate Node.js dependency, use Tailwind Standalone CLI
> **Migration Complete!** All frontends now use Tailwind CSS v4 with the standalone CLI.
> See [Tailwind CSS Build Guide](../../frontend/tailwind-css.md) for current documentation.
---
## Current State
### Two Tailwind Setups (Before Migration)
| Component | Version | Source | Purpose |
|-----------|---------|--------|---------|
| Base styles | 2.2.19 | CDN + local fallback | Core Tailwind utilities for all frontends |
| Custom overrides | 1.4.6 | npm build | Windmill Dashboard theme (admin/vendor) |
### Current Files (Before Migration)
```
package.json # tailwindcss: 1.4.6 (TO BE REMOVED)
package-lock.json # (TO BE REMOVED)
node_modules/ # (TO BE REMOVED)
tailwind.config.js # v1.4 format config (TO BE UPDATED)
postcss.config.js # (TO BE REMOVED)
static/shared/css/tailwind.min.css # CDN fallback v2.2.19 (TO BE REMOVED)
static/admin/css/tailwind.output.css # Built overrides (TO BE REBUILT)
static/vendor/css/tailwind.output.css # Built overrides (TO BE REBUILT)
```
### Current Plugins (TO BE REPLACED)
```json
{
"@tailwindcss/custom-forms": "0.2.1", // DEPRECATED - replaced by @tailwindcss/forms
"tailwindcss-multi-theme": "1.0.3" // DEPRECATED - native darkMode in v3+
}
```
---
## Migration Goals
1. ✅ Eliminate Node.js dependency entirely
2. ✅ Use Tailwind Standalone CLI (single binary, no npm)
3. ✅ Upgrade to Tailwind v4.0 (latest)
4. ✅ Remove CDN dependency (all CSS built locally)
5. ✅ Update config to v4 format
6. ✅ Replace deprecated plugins with native features
7. ✅ Consolidate to single Tailwind version
8. ✅ Verify all frontends work correctly
---
## Step-by-Step Migration
### Phase 1: Install Tailwind Standalone CLI
The standalone CLI bundles Tailwind + Node.js runtime into a single binary.
```bash
# Download latest standalone CLI for Linux x64
curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64
chmod +x tailwindcss-linux-x64
sudo mv tailwindcss-linux-x64 /usr/local/bin/tailwindcss
# Verify installation
tailwindcss --version
```
**For other platforms:**
- macOS (Intel): `tailwindcss-macos-x64`
- macOS (Apple Silicon): `tailwindcss-macos-arm64`
- Windows: `tailwindcss-windows-x64.exe`
- Linux ARM: `tailwindcss-linux-arm64`
### Phase 2: Update tailwind.config.js
Replace the entire config file with v4-compatible format:
```javascript
// tailwind.config.js - Tailwind CSS v4.0 (Standalone CLI)
/** @type {import('tailwindcss').Config} */
module.exports = {
// v4: 'content' replaces 'purge'
content: [
'app/templates/**/*.html',
'static/**/*.js',
],
// v4: safelist for dynamic classes (Alpine.js)
safelist: [
'bg-orange-600',
'bg-green-600',
'bg-red-600',
'bg-blue-600',
'bg-purple-600',
'hover:bg-orange-700',
'hover:bg-green-700',
'hover:bg-red-700',
'hover:bg-blue-700',
'hover:bg-purple-700',
// Status colors
'text-green-600',
'text-red-600',
'text-yellow-600',
'text-blue-600',
'bg-green-100',
'bg-red-100',
'bg-yellow-100',
'bg-blue-100',
],
// v4: darkMode replaces tailwindcss-multi-theme
darkMode: 'class',
theme: {
extend: {
// Custom colors from Windmill Dashboard
colors: {
gray: {
50: '#f9fafb',
100: '#f4f5f7',
200: '#e5e7eb',
300: '#d5d6d7',
400: '#9e9e9e',
500: '#707275',
600: '#4c4f52',
700: '#24262d',
800: '#1a1c23',
900: '#121317',
},
purple: {
50: '#f6f5ff',
100: '#edebfe',
200: '#dcd7fe',
300: '#cabffd',
400: '#ac94fa',
500: '#9061f9',
600: '#7e3af2',
700: '#6c2bd9',
800: '#5521b5',
900: '#4a1d96',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
},
maxHeight: {
'xl': '36rem',
},
},
},
plugins: [
// Note: Standalone CLI includes @tailwindcss/forms by default in v4
// No external plugins needed
],
}
```
### Phase 3: Update CSS Source Files
Update `static/admin/css/tailwind.css`:
```css
/* Tailwind CSS v4 directives */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom component classes */
@layer components {
.form-input {
@apply block w-full rounded-md border-gray-300 shadow-sm
focus:border-purple-500 focus:ring-purple-500;
}
.form-select {
@apply block w-full rounded-md border-gray-300 shadow-sm
focus:border-purple-500 focus:ring-purple-500;
}
.form-checkbox {
@apply rounded border-gray-300 text-purple-600
focus:ring-purple-500;
}
.form-textarea {
@apply block w-full rounded-md border-gray-300 shadow-sm
focus:border-purple-500 focus:ring-purple-500;
}
}
/* Dark mode base overrides */
@layer base {
.dark body {
@apply bg-gray-900 text-gray-200;
}
}
```
### Phase 4: Add Makefile Targets
Add these targets to your Makefile (replaces npm scripts):
```makefile
# =============================================================================
# Tailwind CSS (Standalone CLI - No Node.js Required)
# =============================================================================
.PHONY: tailwind-install tailwind-dev tailwind-build tailwind-watch
# Install Tailwind standalone CLI
tailwind-install:
@echo "Installing Tailwind CSS standalone CLI..."
curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64
chmod +x tailwindcss-linux-x64
sudo mv tailwindcss-linux-x64 /usr/local/bin/tailwindcss
@echo "Tailwind CLI installed: $$(tailwindcss --version)"
# Development build (includes all classes, faster)
tailwind-dev:
@echo "Building Tailwind CSS (development)..."
tailwindcss -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css
tailwindcss -i static/admin/css/tailwind.css -o static/vendor/css/tailwind.output.css
@echo "CSS built successfully"
# Production build (purged and minified)
tailwind-build:
@echo "Building Tailwind CSS (production)..."
tailwindcss -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css --minify
tailwindcss -i static/admin/css/tailwind.css -o static/vendor/css/tailwind.output.css --minify
@echo "CSS built and minified successfully"
# Watch mode for development
tailwind-watch:
@echo "Watching for CSS changes..."
tailwindcss -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css --watch
```
### Phase 5: Update Dark Mode Implementation
**Old approach (tailwindcss-multi-theme):**
```html
<body class="theme-dark">
<div class="bg-white dark:bg-gray-800">
```
**New approach (Tailwind v4 native):**
```html
<html class="dark">
<body>
<div class="bg-white dark:bg-gray-800">
```
**Files to update:**
1. `app/templates/admin/base.html` - Add `dark` class to `<html>` element
2. `app/templates/vendor/base.html` - Add `dark` class to `<html>` element
3. `static/admin/js/init-alpine.js` - Update dark mode toggle logic
4. `static/vendor/js/init-alpine.js` - Update dark mode toggle logic
**JavaScript update:**
```javascript
// Old
document.body.classList.toggle('theme-dark');
// New
document.documentElement.classList.toggle('dark');
```
### Phase 6: Update Base Templates
**Remove CDN loading, use only local built CSS:**
```html
<!-- OLD: CDN with fallback (REMOVE THIS) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
<!-- NEW: Local only (v4) -->
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
```
### Phase 7: Build and Test
```bash
# Build CSS
make tailwind-build
# Start development server
make dev
# Test all frontends:
# - http://localhost:8000/admin/dashboard
# - http://localhost:8000/vendor/{code}/dashboard
# - http://localhost:8000/ (shop)
```
### Phase 8: Remove Node.js Artifacts
After successful testing, remove all Node.js related files:
```bash
# Remove Node.js files
rm -rf node_modules/
rm package.json
rm package-lock.json
rm postcss.config.js
# Remove unused CDN fallback
rm static/shared/css/tailwind.min.css
```
---
## Verification Checklist
- [x] Tailwind CLI installed and working (`tailwindcss --version`)
- [x] Admin dashboard loads correctly
- [x] Vendor dashboard loads correctly
- [x] Shop frontend loads correctly
- [x] Platform pages load correctly
- [x] Dark mode toggle works
- [x] All buttons have correct colors
- [x] Forms display correctly (inputs, selects, checkboxes)
- [x] Responsive design works (mobile/tablet/desktop)
- [x] No console errors
- [x] Production build works (`make tailwind-build`)
- [x] node_modules removed
- [x] package.json removed
- [x] Each frontend has its own CSS source file
- [x] Vendor theming (CSS variables) still works
---
## Breaking Changes Reference
### Class Name Changes (v2 → v4)
| v2.x | v4.x | Notes |
|------|------|-------|
| `overflow-ellipsis` | `text-ellipsis` | Renamed |
| `flex-grow-0` | `grow-0` | Shortened |
| `flex-shrink-0` | `shrink-0` | Shortened |
| `decoration-clone` | `box-decoration-clone` | Renamed |
### Plugin Changes
| Old Plugin | New Approach | Notes |
|------------|--------------|-------|
| `@tailwindcss/custom-forms` | Built-in form styles | Native in v4 |
| `tailwindcss-multi-theme` | `darkMode: 'class'` | Native support |
### Config Changes
| v1.x/v2.x | v4.x |
|-----------|------|
| `purge: [...]` | `content: [...]` |
| `variants: {...}` | Removed (JIT generates all) |
| `mode: 'jit'` | Default (not needed) |
---
## Rollback Plan
If issues arise, the old setup can be restored:
```bash
# Reinstall Node dependencies
npm install
# Rebuild with old config
npm run build
```
---
## Benefits of This Migration
| Aspect | Before | After |
|--------|--------|-------|
| **Node.js required** | Yes | No |
| **Dependencies** | ~200MB node_modules | ~50MB single binary |
| **Build speed** | Slower (npm + PostCSS) | Faster (standalone) |
| **Tailwind versions** | 2 (v1.4 + v2.2) | 1 (v4.0) |
| **CSS loading** | CDN + local | Local only |
| **Maintenance** | npm updates, security patches | Single binary updates |
---
## Resources
- [Tailwind CSS Standalone CLI](https://tailwindcss.com/blog/standalone-cli)
- [Tailwind CSS v4 Documentation](https://tailwindcss.com/docs)
- [Dark Mode in Tailwind](https://tailwindcss.com/docs/dark-mode)
- [Standalone CLI Releases](https://github.com/tailwindlabs/tailwindcss/releases)

View File

@@ -1585,7 +1585,7 @@ async applyFilters() {
// marketplace.js // marketplace.js
return { return {
...data(), ...data(),
currentPage: 'marketplace', // Highlights "Marketplace Import" in sidebar currentPage: 'marketplace', // Highlights "Marketplace" in sidebar
// ... // ...
}; };
@@ -1597,6 +1597,28 @@ return {
}; };
``` ```
### Collapsible Sections
Sidebar sections are collapsible with state persisted to localStorage:
```javascript
// Section keys used in openSections state
{
platformAdmin: true, // Platform Administration (default open)
contentMgmt: false, // Content Management
devTools: false, // Developer Tools
monitoring: false // Platform Monitoring
}
// Toggle a section
toggleSection('devTools');
// Check if section is open
if (openSections.devTools) { ... }
```
See [Sidebar Navigation](../shared/sidebar.md) for full documentation.
--- ---
## 🎨 UI Patterns ## 🎨 UI Patterns

View File

@@ -1,131 +1,158 @@
# Complete Implementation Guide - Testing Hub, Components & Icons # Admin Sidebar Navigation
## 🎉 What's Been Created ## Overview
### ✅ All Files Follow Your Alpine.js Architecture Perfectly! The admin sidebar provides navigation across all admin pages. It features collapsible sections with state persistence, active page indicators, and responsive mobile support.
1. **Testing Hub** - Manual QA tools **File Location:** `app/templates/admin/partials/sidebar.html`
2. **Components Library** - UI component reference with navigation
3. **Icons Browser** - Searchable icon library with copy-to-clipboard
4. **Sidebar Fix** - Active menu indicator for all pages
--- ---
## 📦 Files Created ## Sidebar Structure
### JavaScript Files (Alpine.js Components) The sidebar is organized into the following sections:
1. **[testing-hub.js](computer:///mnt/user-data/outputs/testing-hub.js)** - Testing hub component
2. **[components.js](computer:///mnt/user-data/outputs/components.js)** - Components library component
3. **[icons-page.js](computer:///mnt/user-data/outputs/icons-page.js)** - Icons browser component
### HTML Templates | Section | Collapsible | Pages |
1. **[testing-hub.html](computer:///mnt/user-data/outputs/testing-hub.html)** - Testing hub page |---------|-------------|-------|
2. **[components.html](computer:///mnt/user-data/outputs/components.html)** - Components library page | Dashboard | No | Dashboard |
3. **[icons.html](computer:///mnt/user-data/outputs/icons.html)** - Icons browser page | Platform Administration | Yes | Companies, Vendors, Users, Customers, Marketplace |
| Content Management | Yes | Platform Homepage, Content Pages, Vendor Themes |
### Sidebar Update | Developer Tools | Yes | Components, Icons, Testing Hub, Code Quality |
1. **[sidebar-fixed.html](computer:///mnt/user-data/outputs/sidebar-fixed.html)** - Fixed sidebar with active indicators | Platform Monitoring | Yes | Import Jobs, Application Logs |
| Settings | No | Settings |
### Documentation
1. **[ARCHITECTURE_CONFIRMATION.md](computer:///mnt/user-data/outputs/ARCHITECTURE_CONFIRMATION.md)** - Architecture confirmation
--- ---
## 🔧 Installation Steps ## Collapsible Sections
### Step 1: Install JavaScript Files ### How It Works
```bash Sections can be expanded/collapsed by clicking the section header. The state is persisted to `localStorage` so sections remain open/closed across page navigation and browser sessions.
# Copy to your static directory
cp outputs/testing-hub.js static/admin/js/testing-hub.js ### State Management
cp outputs/components.js static/admin/js/components.js
cp outputs/icons-page.js static/admin/js/icons-page.js **File:** `static/admin/js/init-alpine.js`
```javascript
// Default state: Platform Administration open, others closed
const defaultSections = {
platformAdmin: true,
contentMgmt: false,
devTools: false,
monitoring: false
};
// State stored in localStorage under this key
const SIDEBAR_STORAGE_KEY = 'admin_sidebar_sections';
``` ```
### Step 2: Install HTML Templates ### Available Methods
```bash | Method | Description |
# Copy to your templates directory |--------|-------------|
cp outputs/testing-hub.html app/templates/admin/testing-hub.html | `toggleSection(section)` | Toggle a section open/closed |
cp outputs/components.html app/templates/admin/components.html | `expandSectionForCurrentPage()` | Auto-expand section containing current page |
cp outputs/icons.html app/templates/admin/icons.html | `openSections.platformAdmin` | Check if Platform Administration is open |
``` | `openSections.contentMgmt` | Check if Content Management is open |
| `openSections.devTools` | Check if Developer Tools is open |
| `openSections.monitoring` | Check if Platform Monitoring is open |
### Step 3: Fix Sidebar (IMPORTANT!) ### CSS Transitions
```bash Sections animate smoothly using CSS transitions (no plugins required):
# Replace your current sidebar
cp outputs/sidebar-fixed.html app/templates/partials/sidebar.html ```html
``` <ul
x-show="openSections.platformAdmin"
### Step 4: Add Icons Route x-transition:enter="transition-all duration-200 ease-out"
x-transition:enter-start="opacity-0 -translate-y-2"
Update `app/api/v1/admin/pages.py` - add this route: x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition-all duration-150 ease-in"
```python x-transition:leave-start="opacity-100 translate-y-0"
@router.get("/icons", response_class=HTMLResponse, include_in_schema=False) x-transition:leave-end="opacity-0 -translate-y-2"
async def admin_icons_page( class="mt-1 overflow-hidden"
request: Request, >
current_user: User = Depends(get_current_admin_from_cookie_or_header), ```
db: Session = Depends(get_db)
): ### Chevron Icon Rotation
"""
Render icons browser page. The chevron icon rotates 180 degrees when a section is expanded:
Browse and search all available icons.
""" ```html
return templates.TemplateResponse( <span
"admin/icons.html", x-html="$icon('chevron-down', 'w-4 h-4 transition-transform duration-200')"
{ :class="{ 'rotate-180': openSections.platformAdmin }"
"request": request, ></span>
"user": current_user, ```
}
) ---
```
## Page-to-Section Mapping
### Step 5: Verify Icons Are Updated
Pages are mapped to their parent sections for auto-expansion:
Make sure you're using the updated `icons-updated.js` from earlier:
```javascript
```bash const pageSectionMap = {
cp outputs/icons-updated.js static/shared/js/icons.js // Platform Administration
``` companies: 'platformAdmin',
vendors: 'platformAdmin',
### Step 6: Restart Server users: 'platformAdmin',
customers: 'platformAdmin',
```bash marketplace: 'platformAdmin',
# Stop current server (Ctrl+C) // Content Management
# Start again 'platform-homepage': 'contentMgmt',
uvicorn app.main:app --reload 'content-pages': 'contentMgmt',
``` 'vendor-theme': 'contentMgmt',
// Developer Tools
--- components: 'devTools',
icons: 'devTools',
## 🐛 Sidebar Active Indicator Fix testing: 'devTools',
'code-quality': 'devTools',
### The Problem // Platform Monitoring
imports: 'monitoring',
You noticed that only the Dashboard menu item showed the vertical purple bar on the left when active. Other menu items didn't show this indicator. logs: 'monitoring'
};
### The Root Cause ```
Each page's JavaScript component needs to set `currentPage` correctly, and the sidebar HTML needs to check for that value. ---
**Before (Only Dashboard worked):** ## Complete Page Mapping
```html
<!-- Only dashboard had the x-show condition --> | Page | `currentPage` Value | Section | URL |
<span x-show="currentPage === 'dashboard'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"></span> |------|---------------------|---------|-----|
``` | Dashboard | `'dashboard'` | (always visible) | `/admin/dashboard` |
| Companies | `'companies'` | Platform Administration | `/admin/companies` |
### The Fix | Vendors | `'vendors'` | Platform Administration | `/admin/vendors` |
| Users | `'users'` | Platform Administration | `/admin/users` |
**1. Sidebar HTML** - Add the indicator `<span>` to EVERY menu item: | Customers | `'customers'` | Platform Administration | `/admin/customers` |
| Marketplace | `'marketplace'` | Platform Administration | `/admin/marketplace` |
| Platform Homepage | `'platform-homepage'` | Content Management | `/admin/platform-homepage` |
| Content Pages | `'content-pages'` | Content Management | `/admin/content-pages` |
| Vendor Themes | `'vendor-theme'` | Content Management | `/admin/vendor-themes` |
| Components | `'components'` | Developer Tools | `/admin/components` |
| Icons | `'icons'` | Developer Tools | `/admin/icons` |
| Testing Hub | `'testing'` | Developer Tools | `/admin/testing` |
| Code Quality | `'code-quality'` | Developer Tools | `/admin/code-quality` |
| Import Jobs | `'imports'` | Platform Monitoring | `/admin/imports` |
| Application Logs | `'logs'` | Platform Monitoring | `/admin/logs` |
| Settings | `'settings'` | (always visible) | `/admin/settings` |
---
## Active Page Indicator
Each menu item shows a purple vertical bar when active:
```html ```html
<!-- Vendors -->
<li class="relative px-6 py-3"> <li class="relative px-6 py-3">
<!-- ✅ Add this span for the purple bar --> <!-- Purple bar indicator (shows when page is active) -->
<span x-show="currentPage === 'vendors'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"></span> <span x-show="currentPage === 'vendors'"
<a :class="currentPage === 'vendors' ? 'text-gray-800 dark:text-gray-100' : ''" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
aria-hidden="true"></span>
<!-- Link with active text styling -->
<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"> href="/admin/vendors">
<span x-html="$icon('shopping-bag')"></span> <span x-html="$icon('shopping-bag')"></span>
<span class="ml-4">Vendors</span> <span class="ml-4">Vendors</span>
@@ -133,329 +160,179 @@ Each page's JavaScript component needs to set `currentPage` correctly, and the s
</li> </li>
``` ```
**2. JavaScript Files** - Each component must set `currentPage`: ### Setting currentPage in Components
Each page component must set `currentPage` to match the sidebar:
```javascript ```javascript
// vendors.js
function adminVendors() { function adminVendors() {
return { return {
...data(), ...data(), // Inherit base (includes openSections)
currentPage: 'vendors', // Must match sidebar check currentPage: 'vendors', // Must match sidebar check
// ... rest of component
};
}
// users.js
function adminUsers() {
return {
...data(),
currentPage: 'users', // ✅ Must match sidebar check
// ... rest of component // ... rest of component
}; };
} }
``` ```
### Complete Page Mapping
| Page | JavaScript `currentPage` | Sidebar Check | URL |
|------|--------------------------|---------------|-----|
| Dashboard | `'dashboard'` | `x-show="currentPage === 'dashboard'"` | `/admin/dashboard` |
| Companies | `'companies'` | `x-show="currentPage === 'companies'"` | `/admin/companies` |
| Vendors | `'vendors'` | `x-show="currentPage === 'vendors'"` | `/admin/vendors` |
| Users | `'users'` | `x-show="currentPage === 'users'"` | `/admin/users` |
| Customers | `'customers'` | `x-show="currentPage === 'customers'"` | `/admin/customers` |
| Marketplace | `'marketplace'` | `x-show="currentPage === 'marketplace'"` | `/admin/marketplace` |
| Imports | `'imports'` | `x-show="currentPage === 'imports'"` | `/admin/imports` |
| Components | `'components'` | `x-show="currentPage === 'components'"` | `/admin/components` |
| Icons | `'icons'` | `x-show="currentPage === 'icons'"` | `/admin/icons` |
| Testing | `'testing'` | `x-show="currentPage === 'testing'"` | `/admin/testing` |
| Settings | `'settings'` | `x-show="currentPage === 'settings'"` | `/admin/settings` |
### Updated Sidebar Structure
The sidebar is organized into the following sections:
**Dashboard:**
- Dashboard
**Platform Administration:**
- Companies
- Vendors
- Users
- Customers
- Marketplace
**Content Management:**
- Platform Homepage
- Content Pages
- Vendor Themes
**Developer Tools:**
- Components
- Icons
- Testing Hub
- Code Quality
**Platform Monitoring:**
- Import Jobs
- Application Logs
**Settings:**
- Settings
Each section is properly separated with dividers and all menu items have active indicators.
--- ---
## ✨ New Features ## Jinja2 Macros
### Testing Hub The sidebar uses Jinja2 macros for DRY code:
- **2 Test Suites**: Auth Flow and Data Migration
- **Stats Cards**: Quick metrics overview
- **Interactive Cards**: Click to run tests
- **Best Practices**: Testing guidelines
- **Resource Links**: To Components and Icons pages
### Components Library ### section_header
- **Sticky Section Navigation**: Jump to Forms, Buttons, Cards, etc.
- **Hash-based URLs**: Bookmarkable sections (#forms, #buttons)
- **Copy to Clipboard**: Click to copy component code
- **Live Examples**: All components with real Alpine.js
- **Dark Mode**: All examples support dark mode
### Icons Browser Creates a collapsible section header with chevron:
- **Search Functionality**: Filter icons by name
- **Category Navigation**: Browse by category
- **Live Preview**: See icons in multiple sizes
- **Copy Icon Name**: Quick copy to clipboard
- **Copy Usage Code**: Copy Alpine.js usage code
- **Selected Icon Details**: Full preview and size examples
- **Auto-categorization**: Icons organized automatically
--- ```jinja2
{% macro section_header(title, section_key) %}
## 🎯 How Each Feature Works <button
@click="toggleSection('{{ section_key }}')"
### Components Library Navigation class="flex items-center justify-between w-full px-6 py-2 ..."
>
1. **Click a section** in the left sidebar <span>{{ title }}</span>
2. **Page scrolls** to that section smoothly <span x-html="$icon('chevron-down', '...')"
3. **URL updates** with hash (#forms) :class="{ 'rotate-180': openSections.{{ section_key }} }"></span>
4. **Active section** is highlighted in purple </button>
5. **Bookmarkable**: Share URL with #section {% endmacro %}
```javascript
// How it works
goToSection(sectionId) {
this.activeSection = sectionId;
window.location.hash = sectionId;
// Smooth scroll
document.getElementById(sectionId).scrollIntoView({ behavior: 'smooth' });
}
``` ```
### Icons Browser Search ### section_content
1. **Type in search box** - filters as you type Wraps section items with collapse animation:
2. **Click category pill** - filters by category
3. **Click any icon** - shows details panel
4. **Hover icon** - shows copy buttons
5. **Click copy** - copies to clipboard
```javascript ```jinja2
// How it works {% macro section_content(section_key) %}
filterIcons() { <ul x-show="openSections.{{ section_key }}" x-transition:...>
let icons = this.allIcons; {{ caller() }}
</ul>
// Filter by category {% endmacro %}
if (this.activeCategory !== 'all') {
icons = icons.filter(icon => icon.category === this.activeCategory);
}
// Filter by search
if (this.searchQuery.trim()) {
const query = this.searchQuery.toLowerCase();
icons = icons.filter(icon => icon.name.toLowerCase().includes(query));
}
this.filteredIcons = icons;
}
``` ```
### Testing Hub Navigation ### menu_item
1. **View stats** at the top Creates a menu item with active indicator:
2. **Read test suite cards** with features
3. **Click "Run Tests"** to go to test page
4. **Read best practices** before testing
--- ```jinja2
{% macro menu_item(page_id, url, icon, label) %}
## 🧪 Testing Checklist
After installation, verify:
### General
- [ ] Server starts without errors
- [ ] All routes load successfully
- [ ] Icons display correctly everywhere
- [ ] Dark mode works on all pages
### Sidebar
- [ ] Dashboard shows purple bar when active
- [ ] Vendors shows purple bar when active
- [ ] Users shows purple bar when active
- [ ] Components shows purple bar when active
- [ ] Icons shows purple bar when active
- [ ] Testing shows purple bar when active
- [ ] Text is bold/highlighted on active page
### Testing Hub
- [ ] Page loads at `/admin/testing`
- [ ] Stats cards display correctly
- [ ] Test suite cards are clickable
- [ ] Icons render properly
- [ ] Links to other pages work
### Components Library
- [ ] Page loads at `/admin/components`
- [ ] Section navigation works
- [ ] Clicking section scrolls to it
- [ ] URL hash updates (#forms, etc.)
- [ ] Copy buttons work
- [ ] All form examples render
- [ ] Toast examples work
### Icons Browser
- [ ] Page loads at `/admin/icons`
- [ ] Shows correct icon count
- [ ] Search filters icons
- [ ] Category pills filter icons
- [ ] Clicking icon shows details
- [ ] Copy name button works
- [ ] Copy usage button works
- [ ] Preview shows multiple sizes
---
## 🎨 Customization
### Adding More Test Suites
Edit `testing-hub.js`:
```javascript
testSuites: [
// ... existing suites
{
id: 'new-suite',
name: 'New Test Suite',
description: 'Description here',
url: '/admin/test/new-suite',
icon: 'icon-name',
color: 'blue', // blue, orange, green, purple
testCount: 5,
features: [
'Feature 1',
'Feature 2'
]
}
]
```
### Adding More Component Sections
Edit `components.js`:
```javascript
sections: [
// ... existing sections
{ id: 'new-section', name: 'New Section', icon: 'icon-name' }
]
```
Then add the section HTML in `components.html`:
```html
<section id="new-section">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2>New Section</h2>
<!-- Your components here -->
</div>
</section>
```
### Adding More Icon Categories
Edit `icons-page.js`:
```javascript
categories: [
// ... existing categories
{ id: 'new-category', name: 'New Category', icon: 'icon-name' }
]
```
And update the `categorizeIcon()` function:
```javascript
categoryMap: {
// ... existing mappings
'new-category': ['keyword1', 'keyword2']
}
```
---
## 📚 Quick Reference
### Alpine.js Component Pattern
```javascript
function yourPageComponent() {
return {
...data(), // ✅ Inherit base
currentPage: 'name', // ✅ Set page ID
async init() {
if (window._yourPageInitialized) return;
window._yourPageInitialized = true;
// Your init code
}
};
}
```
### Sidebar Menu Item Pattern
```html
<li class="relative px-6 py-3"> <li class="relative px-6 py-3">
<!-- Active indicator --> <span x-show="currentPage === '{{ page_id }}'" class="..."></span>
<span x-show="currentPage === 'page-name'" <a href="{{ url }}">
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"> <span x-html="$icon('{{ icon }}')"></span>
</span> <span class="ml-4">{{ label }}</span>
<!-- Link -->
<a :class="currentPage === 'page-name' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/admin/page-name">
<span x-html="$icon('icon-name')"></span>
<span class="ml-4">Page Name</span>
</a> </a>
</li> </li>
{% endmacro %}
```
### Usage Example
```jinja2
{{ section_header('Platform Administration', 'platformAdmin') }}
{% call section_content('platformAdmin') %}
{{ menu_item('companies', '/admin/companies', 'office-building', 'Companies') }}
{{ menu_item('vendors', '/admin/vendors', 'shopping-bag', 'Vendors') }}
{% endcall %}
``` ```
--- ---
## 🎯 Summary ## Adding a New Page
**Architecture:** ✅ All files follow your Alpine.js patterns perfectly ### Step 1: Add Route
**Sidebar:** ✅ Fixed - all menu items now show active indicator
**Testing Hub:** ✅ Complete with test suites and navigation
**Components:** ✅ Complete with section navigation and copy feature
**Icons:** ✅ Complete with search, categories, and copy features
**Total Files:** 7 (3 JS + 3 HTML + 1 Sidebar) Add the route in `app/routes/admin_pages.py`:
**Lines of Code:** ~2000+
**Features Added:** 20+
Everything is ready to install! 🚀 ```python
@router.get("/new-page", response_class=HTMLResponse, include_in_schema=False)
async def admin_new_page(
request: Request,
current_user: User = Depends(get_current_admin_from_cookie_or_header),
):
return templates.TemplateResponse(
"admin/new-page.html",
{"request": request, "user": current_user},
)
```
### Step 2: Create Template
Create `app/templates/admin/new-page.html`:
```jinja2
{% extends "admin/base.html" %}
{% block title %}New Page{% endblock %}
{% block alpine_data %}adminNewPage(){% endblock %}
{% block content %}
<!-- Your content -->
{% endblock %}
```
### Step 3: Create JavaScript Component
Create `static/admin/js/new-page.js`:
```javascript
function adminNewPage() {
return {
...data(),
currentPage: 'new-page', // Must match sidebar
// ...
};
}
```
### Step 4: Add to Sidebar
Edit `app/templates/admin/partials/sidebar.html`:
```jinja2
{# Add to appropriate section #}
{{ menu_item('new-page', '/admin/new-page', 'icon-name', 'New Page') }}
```
### Step 5: Update Page-Section Map (if in collapsible section)
Edit `static/admin/js/init-alpine.js`:
```javascript
const pageSectionMap = {
// ... existing mappings
'new-page': 'platformAdmin', // Add mapping
};
```
---
## Mobile Sidebar
The sidebar has a mobile version that slides in from the left:
- **Toggle:** Click hamburger menu in header
- **Close:** Click outside, press Escape, or navigate
- **State:** Controlled by `isSideMenuOpen`
```javascript
// In base data()
isSideMenuOpen: false,
toggleSideMenu() {
this.isSideMenuOpen = !this.isSideMenuOpen
},
closeSideMenu() {
this.isSideMenuOpen = false
}
```
---
## Testing Checklist
- [ ] Sections expand/collapse on header click
- [ ] Chevron rotates when section opens/closes
- [ ] Section state persists after page reload
- [ ] Section state persists across different pages
- [ ] Active page shows purple bar indicator
- [ ] Active page text is highlighted
- [ ] Mobile sidebar opens/closes correctly
- [ ] Collapsible sections work on mobile
- [ ] All navigation links work correctly

View File

@@ -1,144 +1,86 @@
# UI Components Library Implementation Guide # UI Components Library
**Version:** 2.0
**Last Updated:** December 2024
**Live Reference:** `/admin/components`
---
## Overview ## Overview
This guide covers the implementation of:
1. **Components reference page** - A library showcasing all your UI components
2. **Updated vendor edit page** - Using proper form components
3. **Updated vendor detail page** - Using proper card components
4. **Sidebar navigation** - Adding "Components" menu item
## Files Created The admin panel uses a consistent set of UI components built with Tailwind CSS and Alpine.js. All components support dark mode and are fully accessible.
### 1. Components Library Page **Live Component Library:** Visit `/admin/components` in the admin panel to see all components with copy-paste ready code.
**File:** `app/templates/admin/components.html`
- Complete reference for all UI components
- Quick navigation to sections (Forms, Buttons, Cards, etc.)
- Copy-paste ready examples
- Shows validation states, disabled states, helper text, etc.
### 2. Updated Vendor Edit Page ---
**File:** `app/templates/admin/vendor-edit-updated.html`
- Uses proper form components from your library
- Improved visual hierarchy with card sections
- Better validation state displays (red borders for errors)
- Quick actions section at the top
- Status badges showing current state
- Clean, consistent styling throughout
### 3. Vendor Detail Page ## Page Layout Structure
**File:** `app/templates/admin/vendor-detail.html`
- NEW file (didn't exist before)
- Uses card components to display vendor information
- Status cards showing verification, active status, dates
- Information organized in clear sections
- All vendor data displayed in readable format
- Delete action button
### 4. JavaScript for Detail Page All admin list pages follow a consistent structure:
**File:** `static/admin/js/vendor-detail.js`
- Loads vendor data
- Handles delete action with double confirmation
- Logging for debugging
- Error handling
## Implementation Steps ```
┌─────────────────────────────────────────────────────┐
### Step 1: Add Components Menu to Sidebar │ Page Header (Title + Action Button) │
├─────────────────────────────────────────────────────┤
Update your `app/templates/admin/sidebar.html` (or wherever your sidebar is defined): │ Stats Cards (4 columns on desktop) │
├─────────────────────────────────────────────────────┤
```html │ Search & Filters Bar │
<!-- Add this menu item after "Settings" or wherever appropriate --> ├─────────────────────────────────────────────────────┤
<li class="relative px-6 py-3"> │ Data Table │
<a ├── Table Header │
class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200" ├── Table Rows │
:class="{ 'text-gray-800 dark:text-gray-100': currentPage === 'components' }" └── Pagination │
href="/admin/components" └─────────────────────────────────────────────────────┘
>
<span x-html="$icon('collection', 'w-5 h-5')"></span>
<span class="ml-4">Components</span>
</a>
</li>
``` ```
### Step 2: Add Components Page Route ---
Update your `app/api/v1/admin/pages.py`: ## Form Components
```python ### Basic Input
@router.get("/components", response_class=HTMLResponse, include_in_schema=False)
async def admin_components_page(
request: Request,
current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db)
):
"""
Render UI components reference page.
Shows all available UI components for easy reference.
"""
return templates.TemplateResponse(
"admin/components.html",
{
"request": request,
"user": current_user,
}
)
```
### Step 3: Replace Vendor Edit Template
1. Backup your current: `app/templates/admin/vendor-edit.html`
2. Replace it with: `vendor-edit-updated.html`
3. Keep your existing `vendor-edit.js` (no changes needed)
### Step 4: Add Vendor Detail Template & JavaScript
1. Copy `vendor-detail.html` to `app/templates/admin/vendor-detail.html`
2. Copy `vendor-detail.js` to `static/admin/js/vendor-detail.js`
## Component Usage Guide
### Form Components
#### Basic Input
```html
<label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Label Text <span class="text-red-600">*</span>
</span>
<input
type="text"
x-model="formData.fieldName"
required
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
/>
</label>
```
#### Input with Validation Error
```html ```html
<label class="block mb-4 text-sm"> <label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">Field Label</span> <span class="text-gray-700 dark:text-gray-400">Field Label</span>
<input <input
type="text" type="text"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input" x-model="formData.field"
:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': errors.fieldName }" class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
/> />
<span x-show="errors.fieldName" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.fieldName"></span>
</label> </label>
``` ```
#### Disabled Input ### Required Field with Validation
```html
<label class="block mb-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Email Address <span class="text-red-600">*</span>
</span>
<input
type="email"
x-model="formData.email"
required
:class="{ 'border-red-600 focus:border-red-400 focus:shadow-outline-red': errors.email }"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple form-input"
/>
<span x-show="errors.email" class="text-xs text-red-600 dark:text-red-400 mt-1" x-text="errors.email"></span>
</label>
```
### Disabled/Read-Only Input
```html ```html
<input <input
type="text" type="text"
x-model="data.field"
disabled disabled
value="Read-only value"
class="block w-full mt-1 text-sm bg-gray-100 border-gray-300 rounded-md dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600 cursor-not-allowed" class="block w-full mt-1 text-sm bg-gray-100 border-gray-300 rounded-md dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600 cursor-not-allowed"
/> />
``` ```
#### Textarea ### Textarea
```html ```html
<textarea <textarea
x-model="formData.description" x-model="formData.description"
@@ -147,26 +89,90 @@ async def admin_components_page(
></textarea> ></textarea>
``` ```
### Card Components ### Select
```html
<select
x-model="formData.option"
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-select"
>
<option value="">Select an option</option>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
</select>
```
---
## Button Components
### Primary Button
```html
<button class="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">
Primary Button
</button>
```
### Button with Icon
```html
<button 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">
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Add Item
</button>
```
### Secondary Button
```html
<button class="px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-white border border-gray-300 rounded-lg hover:border-gray-400 focus:outline-none dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800">
Cancel
</button>
```
### Danger Button
```html
<button class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700">
Delete
</button>
```
---
## Card Components
### Stats Card
#### Stats Card
```html ```html
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800"> <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"> <div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('user-group', 'w-5 h-5')"></span> <span x-html="$icon('user-group', 'w-5 h-5')"></span>
</div> </div>
<div> <div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400"> <p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Users Total Users
</p> </p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200"> <p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total">
1,234 0
</p> </p>
</div> </div>
</div> </div>
``` ```
#### Info Card ### Stats Card Colors
| Color | Use Case | Classes |
|-------|----------|---------|
| Blue | Total counts | `text-blue-500 bg-blue-100` |
| Green | Active/Success | `text-green-500 bg-green-100` |
| Red | Inactive/Errors | `text-red-500 bg-red-100` |
| Orange | Warnings/Admin | `text-orange-500 bg-orange-100` |
| Purple | Primary metrics | `text-purple-500 bg-purple-100` |
### Info Card
```html ```html
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800"> <div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200"> <h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
@@ -181,125 +187,281 @@ async def admin_components_page(
</div> </div>
``` ```
### Button Components ---
## Status Badges
### Success Badge
#### Primary Button
```html ```html
<button class="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"> <span class="inline-flex items-center px-3 py-1 text-xs font-semibold leading-tight text-green-700 bg-green-100 rounded-full dark:bg-green-700 dark:text-green-100">
Button Text <span x-html="$icon('check-circle', 'w-3 h-3 mr-1')"></span>
</button> Active
</span>
``` ```
#### Button with Icon ### Warning Badge
```html ```html
<button 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"> <span class="inline-flex items-center px-3 py-1 text-xs font-semibold leading-tight text-orange-700 bg-orange-100 rounded-full dark:bg-orange-700 dark:text-orange-100">
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span> <span x-html="$icon('clock', 'w-3 h-3 mr-1')"></span>
Add Item Pending
</button> </span>
``` ```
#### Secondary Button ### Danger Badge
```html ```html
<button class="px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-white border border-gray-300 rounded-lg hover:border-gray-400 focus:outline-none dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800"> <span class="inline-flex items-center px-3 py-1 text-xs font-semibold leading-tight text-red-700 bg-red-100 rounded-full dark:bg-red-700 dark:text-red-100">
Cancel <span x-html="$icon('x-circle', 'w-3 h-3 mr-1')"></span>
</button> Inactive
</span>
``` ```
## Features of Updated Pages ### Dynamic Badge
### Vendor Edit Page Improvements ```html
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
:class="item.is_active
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
: 'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100'"
x-text="item.is_active ? 'Active' : 'Inactive'">
</span>
```
1. **Quick Actions Section** ---
- Verify/Unverify button
- Activate/Deactivate button
- Status badges showing current state
2. **Better Form Organization** ## Data Tables
- Clear sections with headers
- Two-column layout on desktop
- Helper text for all fields
- Proper validation states
3. **Visual Consistency** ### Basic Table Structure
- Uses standard form components
- Consistent spacing and sizing
- Dark mode support
4. **User Experience** ```html
- Disabled states for read-only fields <div class="w-full overflow-hidden rounded-lg shadow-xs">
- Clear indication of required fields <div class="w-full overflow-x-auto">
- Loading states <table class="w-full whitespace-no-wrap">
- Error messages inline with fields <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">Name</th>
<th class="px-4 py-3">Status</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-for="item in items" :key="item.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-3" x-text="item.name"></td>
<td class="px-4 py-3"><!-- Status badge --></td>
<td class="px-4 py-3"><!-- Action buttons --></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
```
### Vendor Detail Page Features ### Action Buttons
1. **Status Overview** ```html
- 4 stats cards at top showing key metrics <div class="flex items-center space-x-2 text-sm">
- Visual status indicators (colors, icons) <!-- View -->
<a :href="'/admin/resource/' + item.id"
class="flex items-center justify-center p-2 text-blue-600 rounded-lg hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-gray-700"
title="View">
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a>
2. **Information Organization** <!-- Edit -->
- Basic info card <a :href="'/admin/resource/' + item.id + '/edit'"
- Contact info card class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700"
- Business details section title="Edit">
- Owner information section <span x-html="$icon('edit', 'w-5 h-5')"></span>
- Marketplace URLs (if available) </a>
3. **Actions** <!-- Delete -->
- Edit button (goes to edit page) <button @click="deleteItem(item)"
- Delete button (with double confirmation) class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700"
- Back to list button title="Delete">
<span x-html="$icon('delete', 'w-5 h-5')"></span>
</button>
</div>
```
## Quick Reference: Where to Find Components ---
When you need a component, visit `/admin/components` and you'll find: ## Loading & Error States
- **Forms Section**: All input types, validation states, helper text ### Loading State
- **Buttons Section**: All button styles and states
- **Cards Section**: Stats cards, info cards
- **Tables Section**: (from your tables.html)
- **Modals Section**: (from your modals.html)
- **Charts Section**: (from your charts.html)
## Testing Checklist ```html
<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...</p>
</div>
```
- [ ] `/admin/components` page loads and displays all components ### Error Alert
- [ ] Components menu item appears in sidebar
- [ ] `/admin/vendors/{vendor_code}/edit` displays correctly
- [ ] Form validation shows errors in red
- [ ] Quick actions (verify/activate) work
- [ ] `/admin/vendors/{vendor_code}` displays all vendor data
- [ ] Status cards show correct information
- [ ] Edit button navigates to edit page
- [ ] Delete button shows double confirmation
- [ ] All pages work in dark mode
- [ ] All pages are responsive on mobile
## Color Scheme Reference ```html
<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 data</p>
<p class="text-sm" x-text="error"></p>
</div>
</div>
```
Your component library uses these color schemes: ### Empty State
- **Primary**: Purple (`bg-purple-600`, `text-purple-600`) ```html
- **Success**: Green (`bg-green-600`, `text-green-600`) <div class="flex flex-col items-center py-12">
- **Warning**: Orange (`bg-orange-600`, `text-orange-600`) <span x-html="$icon('inbox', 'w-12 h-12 text-gray-400 mb-4')"></span>
- **Danger**: Red (`bg-red-600`, `text-red-600`) <p class="text-lg font-medium text-gray-600 dark:text-gray-400">No items found</p>
- **Info**: Blue (`bg-blue-600`, `text-blue-600`) <p class="text-sm text-gray-500" x-text="filters.search ? 'Try adjusting your search' : 'Create your first item'"></p>
</div>
```
## Next Steps ---
1. Implement the components page route ## Modal Components
2. Add menu item to sidebar
3. Replace vendor-edit.html
4. Add vendor-detail.html and .js
5. Test all pages
6. Apply same patterns to other admin pages (users, imports, etc.)
## Tips ### Confirmation Modal
- Always reference `/admin/components` when building new pages ```html
- Copy component HTML directly from the components page <div
- Maintain consistent spacing and styling x-show="showModal"
- Use Alpine.js x-model for form bindings x-cloak
- Use your icon system with `x-html="$icon('icon-name', 'w-5 h-5')"` class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
- Test in both light and dark modes @click.self="showModal = false">
Enjoy your new component library! 🎨 <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 m-4 max-w-md w-full">
<div class="flex items-start justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
Confirm Action
</h3>
<button @click="showModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</div>
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
Are you sure you want to perform this action?
</p>
<div class="flex justify-end gap-3">
<button @click="showModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600">
Cancel
</button>
<button @click="confirmAction()"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
Confirm
</button>
</div>
</div>
</div>
```
---
## Toast Notifications
Use the global Utils helper:
```javascript
Utils.showToast('Operation successful!', 'success');
Utils.showToast('Something went wrong', 'error');
Utils.showToast('Please check your input', 'warning');
Utils.showToast('Here is some information', 'info');
```
---
## Grid Layouts
### 2 Columns (Desktop)
```html
<div class="grid gap-6 md:grid-cols-2">
<div>Column 1</div>
<div>Column 2</div>
</div>
```
### 4 Columns (Stats Cards)
```html
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Stats cards here -->
</div>
```
---
## Color Reference
| Type | Primary | Success | Warning | Danger | Info |
|------|---------|---------|---------|--------|------|
| Background | `bg-purple-600` | `bg-green-600` | `bg-orange-600` | `bg-red-600` | `bg-blue-600` |
| Text | `text-purple-600` | `text-green-600` | `text-orange-600` | `text-red-600` | `text-blue-600` |
| Light BG | `bg-purple-100` | `bg-green-100` | `bg-orange-100` | `bg-red-100` | `bg-blue-100` |
---
## Common Icons
| Icon | Use Case |
|------|----------|
| `user-group` | Users/Teams |
| `badge-check` | Verified |
| `check-circle` | Success |
| `x-circle` | Error/Inactive |
| `clock` | Pending |
| `calendar` | Dates |
| `edit` | Edit |
| `delete` | Delete |
| `plus` | Add |
| `arrow-left` | Back |
| `exclamation` | Warning |
| `spinner` | Loading |
---
## JavaScript Patterns
### List Page Structure
```javascript
function adminResourceList() {
return {
...data(), // Inherit base layout
currentPage: 'resource-name',
// State
items: [],
loading: false,
error: null,
filters: { search: '', status: '' },
stats: {},
pagination: { page: 1, per_page: 10, total: 0 },
async init() {
await this.loadItems();
await this.loadStats();
},
async loadItems() { /* ... */ },
debouncedSearch() { /* ... */ },
async deleteItem(item) { /* ... */ }
};
}
```
---
## Related Documentation
- [Tailwind CSS](../tailwind-css.md)
- [Tailwind CSS Official Docs](https://tailwindcss.com/docs)
- [Alpine.js Official Docs](https://alpinejs.dev/)

View File

@@ -1,6 +1,6 @@
# Tailwind CSS Build Guide # Tailwind CSS Build Guide
**Version:** 1.0 **Version:** 2.0
**Last Updated:** December 2024 **Last Updated:** December 2024
**Audience:** Frontend Developers **Audience:** Frontend Developers
@@ -8,17 +8,7 @@
## Overview ## Overview
The platform uses [Tailwind CSS](https://tailwindcss.com/) for styling with a **dual-layer architecture**: The platform uses [Tailwind CSS v4](https://tailwindcss.com/) with the **Standalone CLI** - no Node.js required. Each frontend (admin, vendor, shop, platform) has its own dedicated CSS configuration.
1. **Base Layer (CDN):** Tailwind CSS v2.2.19 loaded via CDN with local fallback
2. **Override Layer (npm build):** Custom Windmill Dashboard theme built from Tailwind v1.4.6
This layered approach allows:
- Fast loading via CDN for base utilities
- Custom theme extensions (colors, dark mode, forms) via npm build
- Offline support via local fallback
> **Note:** A migration to Tailwind v3.4 is planned. See [Migration Plan](tailwind-migration-plan.md).
--- ---
@@ -27,180 +17,230 @@ This layered approach allows:
### How It Works ### How It Works
``` ```
Browser loads: Tailwind Standalone CLI (single binary, no npm)
1. CDN Tailwind 2.2.19 (base utilities)
└── Fallback: static/shared/css/tailwind.min.css ├── static/admin/css/tailwind.css → tailwind.output.css (Admin)
├── static/vendor/css/tailwind.css → tailwind.output.css (Vendor)
2. Custom tailwind.output.css (overrides/extensions) ├── static/shop/css/tailwind.css → tailwind.output.css (Shop)
└── Built from: static/admin/css/tailwind.css └── static/platform/css/tailwind.css → tailwind.output.css (Platform)
└── Contains: Windmill Dashboard theme, custom colors, dark mode
``` ```
### Key Files ### Key Files
| File | Version | Purpose | | File | Purpose |
|------|---------|---------| |------|---------|
| CDN `tailwindcss@2.2.19` | 2.2.19 | Base Tailwind utilities | | `~/.local/bin/tailwindcss` | Standalone CLI binary (v4.x) |
| `static/shared/css/tailwind.min.css` | 2.2.19 | Local fallback for CDN | | `static/*/css/tailwind.css` | CSS-first source configuration |
| `tailwind.config.js` | 1.4.6 | Custom build configuration | | `static/*/css/tailwind.output.css` | Compiled output (do not edit) |
| `static/admin/css/tailwind.css` | - | Source file with directives |
| `static/admin/css/tailwind.output.css` | 1.4.6 | Compiled custom styles |
| `static/vendor/css/tailwind.output.css` | 1.4.6 | Compiled custom styles |
### Template Loading Order ### Template Loading
Each frontend loads its own CSS:
```html ```html
<!-- 1. Base Tailwind from CDN (with fallback) --> <!-- Admin -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" <link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
onerror="this.onerror=null; this.href='/static/shared/css/tailwind.min.css';">
<!-- 2. Custom overrides (built via npm) --> <!-- Vendor -->
<link rel="stylesheet" href="/static/admin/css/tailwind.output.css" /> <link rel="stylesheet" href="{{ url_for('static', path='vendor/css/tailwind.output.css') }}" />
<!-- Shop -->
<link rel="stylesheet" href="{{ url_for('static', path='shop/css/tailwind.output.css') }}" />
<!-- Platform -->
<link rel="stylesheet" href="{{ url_for('static', path='platform/css/tailwind.output.css') }}" />
``` ```
See [CDN Fallback Strategy](cdn-fallback-strategy.md) for details on offline support.
--- ---
## How Tailwind Purging Works ## CSS-First Configuration (Tailwind v4)
Tailwind generates thousands of utility classes. To keep the CSS file small, it "purges" (removes) unused classes by scanning your source files. Tailwind v4 uses **CSS-first configuration** instead of `tailwind.config.js`. All customization happens directly in CSS files.
### Content Paths ### Source File Structure
The `purge.content` array in `tailwind.config.js` tells Tailwind where to look for class usage: ```css
/* static/admin/css/tailwind.css */
```javascript /* Import Tailwind */
purge: { @import "tailwindcss";
content: [
'public/**/*.html', /* Content sources for tree-shaking */
'app/templates/**/*.html', // Jinja2 templates @source "../../../app/templates/admin/**/*.html";
'static/**/*.js', // Alpine.js components @source "../../js/**/*.js";
],
// ... /* Custom theme (colors, fonts, spacing) */
@theme {
--color-gray-50: #f9fafb;
--color-gray-900: #121317;
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
}
/* Dark mode variant */
@variant dark (&:where(.dark, .dark *));
/* Custom utilities */
@layer utilities {
.shadow-outline-purple {
box-shadow: 0 0 0 3px hsla(262, 97%, 81%, 0.45);
}
}
/* Custom components */
@layer components {
.form-input { ... }
.btn-primary { ... }
} }
``` ```
### Safelist ### Key Directives
Some classes are used dynamically in Alpine.js expressions and can't be detected by scanning. These must be added to the `safelist`: | Directive | Purpose | Example |
|-----------|---------|---------|
| `@import "tailwindcss"` | Import Tailwind base | Required at top |
| `@source` | Content paths for purging | `@source "../../../app/templates/**/*.html"` |
| `@theme` | Custom design tokens | `--color-purple-600: #7e3af2;` |
| `@variant` | Custom variants | `@variant dark (&:where(.dark, .dark *))` |
| `@layer utilities` | Custom utility classes | `.shadow-outline-*` |
| `@layer components` | Custom components | `.form-input`, `.btn-*` |
```javascript ---
purge: {
content: [...], ## Custom Color Palette
safelist: [
'bg-orange-600', All frontends share the same Windmill Dashboard color palette:
'bg-green-600',
'bg-red-600', ```css
'hover:bg-orange-700', @theme {
'hover:bg-green-700', /* Gray (custom darker palette) */
'hover:bg-red-700', --color-gray-50: #f9fafb;
], --color-gray-100: #f4f5f7;
--color-gray-200: #e5e7eb;
--color-gray-300: #d5d6d7;
--color-gray-400: #9e9e9e;
--color-gray-500: #707275;
--color-gray-600: #4c4f52;
--color-gray-700: #24262d;
--color-gray-800: #1a1c23;
--color-gray-900: #121317;
/* Purple (primary) */
--color-purple-600: #7e3af2;
/* Plus: red, orange, yellow, green, teal, blue, indigo, pink, cool-gray */
} }
``` ```
**When to add to safelist:**
- Classes used in Alpine.js `:class` bindings with dynamic conditions
- Classes constructed from variables (e.g., `bg-${color}-500`)
- Classes used only in JavaScript, not in HTML templates
--- ---
## Building Tailwind CSS ## Building Tailwind CSS
### Prerequisites ### Prerequisites
Install Node.js dependencies (one-time setup): Install the standalone CLI (one-time setup):
```bash ```bash
npm install make tailwind-install
``` ```
Or using Make: Or manually:
```bash ```bash
make npm-install curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64
chmod +x tailwindcss-linux-x64
mv tailwindcss-linux-x64 ~/.local/bin/tailwindcss
``` ```
### Development Build ### Development Build
For development, build without purging (includes all classes, larger file): Build all frontends (includes all classes, larger files):
```bash ```bash
# Build admin CSS
npm run tailwind:admin
# Build vendor CSS
npm run tailwind:vendor
# Or using Make
make tailwind-dev make tailwind-dev
``` ```
### Production Build ### Production Build
For production, build with purging and minification (smaller file): Build with minification (smaller files):
```bash ```bash
npm run build
# Or using Make
make tailwind-build make tailwind-build
``` ```
--- ### Watch Mode
## Available npm Scripts Watch for changes during development:
| Script | Command | Description | ```bash
|--------|---------|-------------| make tailwind-watch
| `npm run tailwind:admin` | Build admin CSS (dev) | Fast, includes all classes |
| `npm run tailwind:vendor` | Build vendor CSS (dev) | Fast, includes all classes |
| `npm run build:admin` | Build admin CSS (prod) | Purged and minified |
| `npm run build:vendor` | Build vendor CSS (prod) | Purged and minified |
| `npm run build` | Build all (prod) | Runs both production builds |
---
## Adding New Utility Classes
If you need a class that doesn't exist in the compiled CSS:
### Option 1: Check if it's being purged
The class might exist but is being removed. Add it to the `safelist` in `tailwind.config.js`:
```javascript
safelist: [
'your-new-class',
// ...
]
``` ```
### Option 2: Extend the theme ---
Add custom values to `tailwind.config.js`: ## Makefile Targets
| Target | Description |
|--------|-------------|
| `make tailwind-install` | Install Tailwind standalone CLI |
| `make tailwind-dev` | Build all CSS (development) |
| `make tailwind-build` | Build all CSS (production, minified) |
| `make tailwind-watch` | Watch for changes |
---
## Dark Mode
The platform uses class-based dark mode with the `.dark` class on the `<html>` element:
```html
<html :class="{ 'dark': dark }">
<body class="bg-white dark:bg-gray-900">
```
Toggle dark mode with JavaScript:
```javascript ```javascript
theme: { document.documentElement.classList.toggle('dark');
extend: { localStorage.setItem('darkMode', dark);
colors: { ```
'brand': '#123456',
}, ---
spacing: {
'128': '32rem', ## Adding Custom Utilities
},
}, Add custom utilities in the source CSS file:
```css
@layer utilities {
.shadow-outline-purple {
box-shadow: 0 0 0 3px hsla(262, 97%, 81%, 0.45);
}
.text-balance {
text-wrap: balance;
}
} }
``` ```
### Option 3: Rebuild CSS ---
After any config change, rebuild the CSS: ## Adding Custom Components
```bash Add reusable component classes:
make tailwind-dev
```css
@layer components {
.form-input {
@apply block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm;
@apply focus:border-purple-500 focus:ring-1 focus:ring-purple-500;
@apply dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200;
}
.btn-primary {
@apply px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg;
@apply hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500;
}
}
``` ```
--- ---
@@ -209,105 +249,51 @@ make tailwind-dev
### Classes not appearing ### Classes not appearing
1. **Check purge paths** - Ensure your file is in a scanned directory 1. **Check @source paths** - Ensure your templates are in a scanned directory
2. **Check safelist** - Dynamic classes need to be safelisted 2. **Check class exists** - Tailwind v4 may have renamed some classes
3. **Rebuild CSS** - Run `make tailwind-dev` after config changes 3. **Rebuild CSS** - Run `make tailwind-dev` after config changes
### Dynamic classes in Alpine.js ### Dynamic classes in Alpine.js
**Problem:** Classes in `:class` bindings may be purged. Tailwind v4 uses JIT compilation and scans for complete class names. Dynamic classes work automatically if the full class name appears in your templates:
```html ```html
<!-- This class might be purged --> <!-- This works - full class names visible -->
<div :class="isActive ? 'bg-green-600' : 'bg-red-600'"> <div :class="isActive ? 'bg-green-600' : 'bg-red-600'">
``` ```
**Solution:** Add to safelist: ### Class name changes (v2 → v4)
```javascript | v2.x | v4.x |
safelist: ['bg-green-600', 'bg-red-600'] |------|------|
``` | `overflow-ellipsis` | `text-ellipsis` |
| `flex-grow-0` | `grow-0` |
### Large CSS file in development | `flex-shrink-0` | `shrink-0` |
Development builds include all Tailwind classes (~1.2MB). This is normal.
Production builds purge unused classes, resulting in much smaller files (~50-100KB typically).
---
## Configuration Reference
### tailwind.config.js Structure
```javascript
module.exports = {
// Where to scan for class usage
purge: {
content: [...],
safelist: [...],
},
// Theme customization
theme: {
// Override defaults
colors: {...},
// Extend defaults
extend: {
fontFamily: {...},
maxHeight: {...},
},
},
// Variant configuration (hover, focus, dark mode, etc.)
variants: {
backgroundColor: ['hover', 'focus', 'dark', 'dark:hover'],
textColor: ['hover', 'dark'],
// ...
},
// Plugins
plugins: [
require('tailwindcss-multi-theme'),
require('@tailwindcss/custom-forms'),
],
}
```
### Dark Mode
The platform uses `tailwindcss-multi-theme` for dark mode. Dark mode classes use the `.theme-dark` parent selector:
```css
/* Light mode */
.bg-white { ... }
/* Dark mode */
.theme-dark .dark\:bg-gray-800 { ... }
```
--- ---
## Best Practices ## Best Practices
1. **Always rebuild after config changes** 1. **Always rebuild after CSS changes**
```bash ```bash
make tailwind-dev make tailwind-dev
``` ```
2. **Add dynamic classes to safelist** to prevent purging 2. **Use production builds for deployment**
```bash
make tailwind-build
```
3. **Use production builds for deployment** to minimize file size 3. **Keep each frontend's CSS isolated** - Don't cross-import between frontends
4. **Check existing classes first** before adding custom ones - Tailwind likely has what you need 4. **Use @apply sparingly** - Prefer utility classes in templates when possible
5. **Use consistent color scales** (e.g., `purple-600`, `purple-700`) for hover states 5. **Test in both light and dark modes**
--- ---
## Related Documentation ## Related Documentation
- [Frontend Overview](overview.md)
- [UI Components](shared/ui-components.md) - [UI Components](shared/ui-components.md)
- [Tailwind CSS Official Docs](https://tailwindcss.com/docs) - [Tailwind CSS Official Docs](https://tailwindcss.com/docs)
- [Tailwind CSS v4 Blog Post](https://tailwindcss.com/blog/tailwindcss-v4)

View File

@@ -1,361 +0,0 @@
# Tailwind CSS Migration Plan: v1.4/v2.2 → v3.4
**Created:** December 2024
**Status:** Planned
**Estimated Time:** 2-3 hours
---
## Current State
### Two Tailwind Setups
| Component | Version | Source | Purpose |
|-----------|---------|--------|---------|
| Base styles | 2.2.19 | CDN + local fallback | Core Tailwind utilities for all frontends |
| Custom overrides | 1.4.6 | npm build | Windmill Dashboard theme (admin/vendor) |
### Current Files
```
package.json # tailwindcss: 1.4.6
tailwind.config.js # v1.4 format config
static/shared/css/tailwind.min.css # CDN fallback (v2.2.19)
static/admin/css/tailwind.output.css # Built overrides
static/vendor/css/tailwind.output.css # Built overrides
```
### Current Plugins
```json
{
"@tailwindcss/custom-forms": "0.2.1", // DEPRECATED in v3
"tailwindcss-multi-theme": "1.0.3" // May not work with v3
}
```
---
## Migration Goals
1. Upgrade npm Tailwind to v3.4.x (latest stable)
2. Upgrade CDN Tailwind to v3.4.x
3. Update local fallback file
4. Replace deprecated plugins
5. Update config to v3 format
6. Verify all frontends work correctly
---
## Step-by-Step Migration
### Phase 1: Backup Current State
```bash
# Create backup branch
git checkout -b backup/tailwind-v1.4
git add -A
git commit -m "Backup before Tailwind v3 migration"
git checkout master
# Create migration branch
git checkout -b feat/tailwind-v3-upgrade
```
### Phase 2: Update npm Dependencies
```bash
# Remove old packages
npm uninstall tailwindcss tailwindcss-multi-theme @tailwindcss/custom-forms
# Install new packages
npm install -D tailwindcss@latest postcss@latest autoprefixer@latest
npm install -D @tailwindcss/forms @tailwindcss/typography
# Verify versions
npm list tailwindcss
```
**Expected versions:**
- tailwindcss: ^3.4.x
- postcss: ^8.4.x
- autoprefixer: ^10.4.x
### Phase 3: Update tailwind.config.js
Replace the entire config file:
```javascript
// tailwind.config.js - Tailwind CSS v3.4
/** @type {import('tailwindcss').Config} */
module.exports = {
// v3: 'content' replaces 'purge'
content: [
'app/templates/**/*.html',
'static/**/*.js',
],
// v3: safelist for dynamic classes
safelist: [
'bg-orange-600',
'bg-green-600',
'bg-red-600',
'hover:bg-orange-700',
'hover:bg-green-700',
'hover:bg-red-700',
// Add any other dynamic classes used in Alpine.js
],
// v3: darkMode replaces tailwindcss-multi-theme
darkMode: 'class', // or 'media' for OS preference
theme: {
extend: {
// Custom colors from Windmill Dashboard
colors: {
gray: {
50: '#f9fafb',
100: '#f4f5f7',
200: '#e5e7eb',
300: '#d5d6d7',
400: '#9e9e9e',
500: '#707275',
600: '#4c4f52',
700: '#24262d',
800: '#1a1c23',
900: '#121317',
},
purple: {
50: '#f6f5ff',
100: '#edebfe',
200: '#dcd7fe',
300: '#cabffd',
400: '#ac94fa',
500: '#9061f9',
600: '#7e3af2',
700: '#6c2bd9',
800: '#5521b5',
900: '#4a1d96',
},
// Add other custom colors as needed
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
},
maxHeight: {
'xl': '36rem',
},
},
},
plugins: [
require('@tailwindcss/forms'), // Replaces @tailwindcss/custom-forms
require('@tailwindcss/typography'), // Optional: for prose content
],
}
```
### Phase 4: Update CSS Source File
Update `static/admin/css/tailwind.css`:
```css
/* Tailwind CSS v3 directives */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom component classes if needed */
@layer components {
.form-input {
@apply block w-full rounded-md border-gray-300 shadow-sm
focus:border-purple-500 focus:ring-purple-500;
}
.form-select {
@apply block w-full rounded-md border-gray-300 shadow-sm
focus:border-purple-500 focus:ring-purple-500;
}
.form-checkbox {
@apply rounded border-gray-300 text-purple-600
focus:ring-purple-500;
}
}
```
### Phase 5: Update postcss.config.js
```javascript
// postcss.config.js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
...(process.env.NODE_ENV === 'production' ? { cssnano: {} } : {})
}
}
```
### Phase 6: Update package.json Scripts
```json
{
"scripts": {
"tailwind:admin": "npx tailwindcss -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css",
"tailwind:vendor": "npx tailwindcss -i static/admin/css/tailwind.css -o static/vendor/css/tailwind.output.css",
"tailwind:watch": "npx tailwindcss -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css --watch",
"build:admin": "npx tailwindcss -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css --minify",
"build:vendor": "npx tailwindcss -i static/admin/css/tailwind.css -o static/vendor/css/tailwind.output.css --minify",
"build": "npm run build:admin && npm run build:vendor"
}
}
```
### Phase 7: Update Dark Mode Implementation
**Old approach (tailwindcss-multi-theme):**
```html
<body class="theme-dark">
<div class="bg-white dark:bg-gray-800">
```
**New approach (Tailwind v3 native):**
```html
<body class="dark">
<div class="bg-white dark:bg-gray-800">
```
**Files to update:**
1. `app/templates/admin/base.html` - Change `theme-dark` to `dark`
2. `app/templates/vendor/base.html` - Change `theme-dark` to `dark`
3. `static/admin/js/init-alpine.js` - Update dark mode toggle logic
4. `static/vendor/js/init-alpine.js` - Update dark mode toggle logic
**JavaScript update example:**
```javascript
// Old
document.body.classList.toggle('theme-dark');
// New
document.documentElement.classList.toggle('dark');
```
### Phase 8: Update CDN and Local Fallback
**Option A: Use Tailwind Play CDN (development only)**
```html
<script src="https://cdn.tailwindcss.com"></script>
```
**Option B: Build and serve locally (recommended for production)**
Since Tailwind v3 doesn't have a pre-built CDN CSS file (it's JIT-only), the recommended approach is:
1. Remove CDN loading from templates
2. Load only the built `tailwind.output.css`
3. Include all needed utilities in your build
**Update base templates:**
```html
<!-- OLD: CDN with fallback -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/tailwind.min.css') }}';">
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
<!-- NEW: Local only (v3) -->
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
```
### Phase 9: Build and Test
```bash
# Install dependencies
npm install
# Build CSS
npm run tailwind:admin
npm run tailwind:vendor
# Start development server
make dev
# Test all frontends:
# - http://localhost:8000/admin/dashboard
# - http://localhost:8000/vendor/{code}/dashboard
# - http://localhost:8000/ (shop)
```
### Phase 10: Verify Checklist
- [ ] Admin dashboard loads correctly
- [ ] Vendor dashboard loads correctly
- [ ] Shop frontend loads correctly
- [ ] Dark mode toggle works
- [ ] All buttons have correct colors
- [ ] Forms display correctly (inputs, selects, checkboxes)
- [ ] Responsive design works (mobile/tablet/desktop)
- [ ] No console errors
- [ ] Production build works (`npm run build`)
---
## Breaking Changes Reference
### Class Name Changes (v2 → v3)
| v2.x | v3.x | Notes |
|------|------|-------|
| `overflow-ellipsis` | `text-ellipsis` | Renamed |
| `flex-grow-0` | `grow-0` | Shortened |
| `flex-shrink-0` | `shrink-0` | Shortened |
| `decoration-clone` | `box-decoration-clone` | Renamed |
### Plugin Changes
| Old Plugin | New Plugin | Notes |
|------------|------------|-------|
| `@tailwindcss/custom-forms` | `@tailwindcss/forms` | Complete rewrite |
| `tailwindcss-multi-theme` | Built-in `darkMode: 'class'` | Native support |
### Config Changes
| v1.x/v2.x | v3.x |
|-----------|------|
| `purge: [...]` | `content: [...]` |
| `variants: {...}` | Removed (JIT generates all) |
| `mode: 'jit'` | Default (not needed) |
---
## Rollback Plan
If issues arise:
```bash
# Switch back to backup branch
git checkout backup/tailwind-v1.4
# Or reset changes
git checkout master
git reset --hard HEAD~1
```
---
## Post-Migration Tasks
1. Update `docs/frontend/tailwind-css.md` with v3 information
2. Update `docs/frontend/cdn-fallback-strategy.md` (or remove CDN references)
3. Remove `static/shared/css/tailwind.min.css` if no longer needed
4. Update any documentation referencing old class names
5. Consider adding Tailwind IntelliSense VS Code extension config
---
## Resources
- [Tailwind CSS v3 Upgrade Guide](https://tailwindcss.com/docs/upgrade-guide)
- [Tailwind CSS v3 Documentation](https://tailwindcss.com/docs)
- [@tailwindcss/forms Plugin](https://github.com/tailwindlabs/tailwindcss-forms)
- [Dark Mode in Tailwind v3](https://tailwindcss.com/docs/dark-mode)

View File

@@ -1,387 +0,0 @@
# UI Components
This document describes the reusable UI components and patterns used in the Wizamart admin panel.
## Page Layout Structure
All admin list pages follow a consistent structure:
```
┌─────────────────────────────────────────────────────┐
│ Page Header (Title + Action Button) │
├─────────────────────────────────────────────────────┤
│ Stats Cards (4 columns on desktop) │
├─────────────────────────────────────────────────────┤
│ Search & Filters Bar │
├─────────────────────────────────────────────────────┤
│ Data Table │
│ ├── Table Header │
│ ├── Table Rows │
│ └── Pagination │
└─────────────────────────────────────────────────────┘
```
## Page Header
The page header contains the page title and primary action button.
```html
<div class="flex items-center justify-between my-6">
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Page Title
</h2>
<a href="/admin/resource/create"
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">
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Create Resource
</a>
</div>
```
## Stats Cards
Stats cards display key metrics in a 4-column grid layout.
### Structure
```html
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card Template -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-{color}-500 bg-{color}-100 rounded-full dark:text-{color}-100 dark:bg-{color}-500">
<span x-html="$icon('icon-name', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Label
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="value">
0
</p>
</div>
</div>
</div>
```
### Color Options
| Color | Use Case | Example |
|-------|----------|---------|
| `blue` | Total counts | Total Users, Total Companies |
| `green` | Positive status | Active, Verified |
| `red` | Negative status | Inactive, Errors |
| `orange` | Special/Admin | Admin users, Warnings |
| `purple` | Primary/Vendors | Active vendors, Main metrics |
### Icon Style
- Icons should be inside a **circular** container (`rounded-full`)
- Icon size: `w-5 h-5`
- Container padding: `p-3`
## Search & Filters Bar
The search and filters bar provides filtering capabilities for list pages.
### Structure
```html
<div class="mb-6 px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<!-- Search Input -->
<div class="flex-1 max-w-md">
<div class="relative">
<input
type="text"
x-model="filters.search"
@input="debouncedSearch()"
placeholder="Search..."
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:bg-gray-700 dark:text-gray-300"
>
<div class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</div>
</div>
</div>
<!-- Filter Dropdowns -->
<div class="flex gap-3">
<select
x-model="filters.filterName"
@change="loadData()"
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="">All Options</option>
<option value="value1">Option 1</option>
<option value="value2">Option 2</option>
</select>
</div>
</div>
</div>
```
### JavaScript Implementation
```javascript
// State
filters: {
search: '',
status: '',
type: ''
},
// Debounced search function
debouncedSearch() {
if (this._searchTimeout) {
clearTimeout(this._searchTimeout);
}
this._searchTimeout = setTimeout(() => {
this.pagination.page = 1; // Reset to first page
this.loadData();
}, 300);
},
// Load data with filters
async loadData() {
const params = new URLSearchParams();
params.append('page', this.pagination.page);
params.append('per_page', this.pagination.per_page);
if (this.filters.search) {
params.append('search', this.filters.search);
}
if (this.filters.status) {
params.append('status', this.filters.status);
}
const response = await apiClient.get(`/admin/resource?${params}`);
// Handle response...
}
```
### Backend API Support
The API endpoint should support these query parameters:
| Parameter | Type | Description |
|-----------|------|-------------|
| `page` | int | Page number (1-based) |
| `per_page` | int | Items per page |
| `search` | string | Search term (searches multiple fields) |
| `{filter}` | string | Filter by specific field |
Example API implementation:
```python
@router.get("", response_model=ListResponse)
def get_all(
page: int = Query(1, ge=1),
per_page: int = Query(10, ge=1, le=100),
search: str = Query("", description="Search term"),
status: str = Query("", description="Filter by status"),
db: Session = Depends(get_db),
):
query = db.query(Model)
# Apply search
if search:
search_term = f"%{search.lower()}%"
query = query.filter(
(Model.name.ilike(search_term)) |
(Model.email.ilike(search_term))
)
# Apply filters
if status:
query = query.filter(Model.status == status)
# Pagination
total = query.count()
items = query.offset((page - 1) * per_page).limit(per_page).all()
return ListResponse(items=items, total=total, page=page, ...)
```
## Data Table
### Structure
```html
<div 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">Column</th>
<!-- More columns -->
<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-for="item in items" :key="item.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<!-- Columns -->
</tr>
</template>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="px-4 py-3 text-xs font-semibold tracking-wide text-gray-500 uppercase border-t dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<!-- Pagination controls -->
</div>
</div>
```
### Action Buttons
Standard action buttons for table rows:
```html
<div class="flex items-center space-x-2 text-sm">
<!-- View -->
<a :href="'/admin/resource/' + item.id"
class="flex items-center justify-center p-2 text-blue-600 rounded-lg hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-gray-700"
title="View">
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a>
<!-- Edit -->
<a :href="'/admin/resource/' + item.id + '/edit'"
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700"
title="Edit">
<span x-html="$icon('edit', 'w-5 h-5')"></span>
</a>
<!-- Delete -->
<button @click="deleteItem(item)"
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700"
title="Delete">
<span x-html="$icon('delete', 'w-5 h-5')"></span>
</button>
</div>
```
### Status Badges
```html
<!-- Active/Inactive -->
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
:class="item.is_active
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
: 'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100'"
x-text="item.is_active ? 'Active' : 'Inactive'">
</span>
<!-- Role Badge -->
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs capitalize"
:class="{
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': item.role === 'admin',
'text-purple-700 bg-purple-100 dark:bg-purple-700 dark:text-purple-100': item.role === 'vendor'
}"
x-text="item.role">
</span>
```
## Loading & Error States
### Loading State
```html
<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...</p>
</div>
```
### Error State
```html
<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 data</p>
<p class="text-sm" x-text="error"></p>
</div>
</div>
```
### Empty State
```html
<template x-if="items.length === 0">
<tr>
<td colspan="7" 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('inbox', 'w-12 h-12 text-gray-400 mb-4')"></span>
<p class="text-lg font-medium">No items found</p>
<p class="text-sm" x-text="filters.search ? 'Try adjusting your search' : 'Create your first item'"></p>
</div>
</td>
</tr>
</template>
```
## Pages Using These Components
| Page | Stats | Search | Filters |
|------|-------|--------|---------|
| `/admin/users` | Total, Active, Inactive, Admins | Name, Email, Username | Role, Status |
| `/admin/companies` | Total, Verified, Active, Vendors | Name, Email, Owner | Status |
| `/admin/vendors` | Total, Verified, Active, Products | Name, Code, Subdomain | Status, Company |
## JavaScript Module Structure
Each list page follows this pattern:
```javascript
function adminResourceList() {
return {
// Inherit base layout
...data(),
// Page identifier (for sidebar highlighting)
currentPage: 'resource-name',
// State
items: [],
loading: false,
error: null,
filters: { search: '', status: '' },
stats: {},
pagination: { page: 1, per_page: 10, total: 0, pages: 0 },
// Initialize
async init() {
await this.loadItems();
await this.loadStats();
},
// Format helpers
formatDate(dateString) {
return dateString ? Utils.formatDate(dateString) : '-';
},
// Data loading
async loadItems() { /* ... */ },
async loadStats() { /* ... */ },
// Search & filters
debouncedSearch() { /* ... */ },
// Pagination
nextPage() { /* ... */ },
previousPage() { /* ... */ },
// Actions
async deleteItem(item) { /* ... */ }
};
}
```
## Related Documentation
- [Icons Reference](./icons.md)
- [Alpine.js Integration](./alpine-integration.md)
- [Tailwind CSS](./tailwind-css.md)

View File

@@ -564,11 +564,9 @@ def require_vendor_context():
def dependency(request: Request): def dependency(request: Request):
vendor = get_current_vendor(request) vendor = get_current_vendor(request)
if not vendor: if not vendor:
from fastapi import HTTPException from app.exceptions import VendorNotFoundException
raise HTTPException( raise VendorNotFoundException("unknown", identifier_type="context")
status_code=404, detail="Vendor not found or not active"
)
return vendor return vendor
return dependency return dependency

View File

@@ -40,6 +40,7 @@ nav:
- API Consolidation: - API Consolidation:
- Proposal: architecture/api-consolidation-proposal.md - Proposal: architecture/api-consolidation-proposal.md
- Migration Status: architecture/api-migration-status.md - Migration Status: architecture/api-migration-status.md
- Architecture Violations Status: architecture/architecture-violations-status.md
- Diagrams: - Diagrams:
- Multi-Tenant Diagrams: architecture/diagrams/multitenant-diagrams.md - Multi-Tenant Diagrams: architecture/diagrams/multitenant-diagrams.md
- Vendor Domain Diagrams: architecture/diagrams/vendor-domain-diagrams.md - Vendor Domain Diagrams: architecture/diagrams/vendor-domain-diagrams.md
@@ -84,7 +85,6 @@ nav:
- Overview: frontend/overview.md - Overview: frontend/overview.md
- CDN Fallback Strategy: frontend/cdn-fallback-strategy.md - CDN Fallback Strategy: frontend/cdn-fallback-strategy.md
- Tailwind CSS Build: frontend/tailwind-css.md - Tailwind CSS Build: frontend/tailwind-css.md
- Tailwind v3 Migration Plan: frontend/tailwind-migration-plan.md
- Shared Components: - Shared Components:
- UI Components: frontend/shared/ui-components.md - UI Components: frontend/shared/ui-components.md
- UI Components Quick Reference: frontend/shared/ui-components-quick-reference.md - UI Components Quick Reference: frontend/shared/ui-components-quick-reference.md
@@ -118,7 +118,13 @@ nav:
- Customer Authentication: - Customer Authentication:
- Implementation Guide: development/customer-authentication-implementation.md - Implementation Guide: development/customer-authentication-implementation.md
- Quick Summary: development/customer-auth-summary.md - Quick Summary: development/customer-auth-summary.md
- Database Migrations: development/migration/database-migrations.md - Migrations:
- Database Migrations: development/migration/database-migrations.md
- Tailwind CSS Migration: development/migration/tailwind-migration-plan.md
- Makefile Refactoring: development/migration/makefile-refactoring-complete.md
- SVC-006 Migration Plan: development/migration/svc-006-migration-plan.md
- Vendor Contact Inheritance: development/migration/vendor-contact-inheritance.md
- Seed Scripts Audit: development/seed-scripts-audit.md
- Database Seeder: - Database Seeder:
- Documentation: development/database-seeder/database-seeder-documentation.md - Documentation: development/database-seeder/database-seeder-documentation.md
- Makefile Guide: development/database-seeder/makefile-database-seeder.md - Makefile Guide: development/database-seeder/makefile-database-seeder.md

View File

@@ -513,3 +513,38 @@ class FileLogResponse(BaseModel):
last_modified: datetime last_modified: datetime
lines: list[str] lines: list[str]
total_lines: int total_lines: int
class LogFileInfo(BaseModel):
"""Log file info for listing."""
filename: str
size_bytes: int
last_modified: datetime
class LogFileListResponse(BaseModel):
"""Response for listing log files."""
files: list[LogFileInfo]
class LogDeleteResponse(BaseModel):
"""Response for log deletion."""
message: str
class LogCleanupResponse(BaseModel):
"""Response for log cleanup operation."""
message: str
deleted_count: int
class LogSettingsUpdateResponse(BaseModel):
"""Response for log settings update."""
message: str
updated_fields: list[str]
note: str | None = None

View File

@@ -121,3 +121,59 @@ class UserListResponse(BaseModel):
page: int page: int
per_page: int per_page: int
pages: int pages: int
class UserSearchItem(BaseModel):
"""Schema for a single user search result."""
id: int
username: str
email: str
is_active: bool
class UserSearchResponse(BaseModel):
"""Schema for user search results."""
users: list[UserSearchItem]
class UserStatusToggleResponse(BaseModel):
"""Schema for user status toggle response."""
message: str
is_active: bool
class UserDeleteResponse(BaseModel):
"""Schema for user delete response."""
message: str
class LogoutResponse(BaseModel):
"""Schema for logout response."""
message: str
class PasswordResetRequestResponse(BaseModel):
"""Schema for password reset request response."""
message: str
class PasswordResetResponse(BaseModel):
"""Schema for password reset response."""
message: str
class VendorUserResponse(BaseModel):
"""Schema for vendor user info in auth context."""
id: int
username: str
email: str
role: str
is_active: bool

View File

@@ -71,3 +71,17 @@ class ProductListResponse(BaseModel):
total: int total: int
skip: int skip: int
limit: int limit: int
class ProductDeleteResponse(BaseModel):
"""Response for product deletion."""
message: str
class ProductToggleResponse(BaseModel):
"""Response for product toggle operations (active/featured)."""
message: str
is_active: bool | None = None
is_featured: bool | None = None

View File

@@ -1,55 +0,0 @@
{
"name": "windmill-dashboard",
"version": "1.0.2",
"description": "A multi theme, completely accessible, with components and pages examples, ready for production dashboard.",
"scripts": {
"tailwind:admin": "tailwindcss build static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css",
"tailwind:vendor": "tailwindcss build static/admin/css/tailwind.css -o static/vendor/css/tailwind.output.css",
"build:admin": "env NODE_ENV=production postcss static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css",
"build:vendor": "env NODE_ENV=production postcss static/admin/css/tailwind.css -o static/vendor/css/tailwind.output.css",
"build": "npm run build:admin && npm run build:vendor",
"cz": "git-cz",
"release": "release-it"
},
"author": "Estevan Maito <ejmaito@gmail.com>",
"license": "MIT",
"devDependencies": {
"@release-it/conventional-changelog": "1.1.4",
"@tailwindcss/custom-forms": "0.2.1",
"autoprefixer": "9.8.0",
"color": "3.1.2",
"commitizen": "4.1.2",
"cssnano": "4.1.10",
"cz-conventional-changelog": "3.2.0",
"postcss-cli": "7.1.1",
"release-it": "13.6.4",
"tailwindcss": "1.4.6",
"tailwindcss-multi-theme": "1.0.3"
},
"keywords": [
"tailwind",
"windmill",
"dashboard",
"template",
"admin"
],
"release-it": {
"github": {
"release": true
},
"npm": {
"publish": false
},
"plugins": {
"@release-it/conventional-changelog": {
"preset": "angular",
"infile": "CHANGELOG.md"
}
}
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}
}

View File

@@ -1,9 +0,0 @@
module.exports = {
plugins: [
require('tailwindcss'),
require('autoprefixer'),
require('cssnano')({
preset: 'default',
}),
],
}

View File

@@ -119,18 +119,53 @@ python_classes = ["Test*"]
python_functions = ["test_*"] python_functions = ["test_*"]
addopts = [ addopts = [
"-v", "-v",
"--tb=short",
"--strict-markers", "--strict-markers",
"--strict-config", "--strict-config",
"--color=yes", "--color=yes",
"--durations=10",
"--showlocals",
"-ra",
"--cov=app",
"--cov=models",
"--cov=middleware",
"--cov-report=term-missing",
"--cov-report=html:htmlcov",
"--cov-fail-under=80",
] ]
markers = [ markers = [
"unit: Unit tests", "unit: marks tests as unit tests - fast, isolated components",
"integration: Integration tests", "integration: marks tests as integration tests - multiple components working together",
"slow: Slow running tests", "system: marks tests as system tests - full application behavior",
"e2e: marks tests as end-to-end tests - complete user workflows",
"slow: marks tests as slow running tests (deselect with '-m \"not slow\"')",
"performance: marks tests as performance and load tests",
"auth: marks tests as authentication and authorization tests",
"products: marks tests as product management functionality",
"inventory: marks tests as inventory and inventory management",
"vendors: marks tests as vendor management functionality",
"admin: marks tests as admin functionality and permissions",
"marketplace: marks tests as marketplace import functionality",
"stats: marks tests as statistics and reporting",
"database: marks tests as tests that require database operations",
"external: marks tests as tests that require external services",
"api: marks tests as API endpoint tests",
"security: marks tests as security-related tests",
"ci: marks tests as tests that should only run in CI",
"dev: marks tests as development-specific tests",
] ]
filterwarnings = [ filterwarnings = [
"ignore::UserWarning",
"ignore::DeprecationWarning", "ignore::DeprecationWarning",
"ignore::PendingDeprecationWarning",
"ignore::sqlalchemy.exc.SAWarning",
] ]
timeout = 300
timeout_method = "thread"
log_cli = true
log_cli_level = "INFO"
log_cli_format = "%(asctime)s [%(levelname)8s] %(name)s: %(message)s"
log_cli_date_format = "%Y-%m-%d %H:%M:%S"
# ============================================================================= # =============================================================================
# COVERAGE # COVERAGE

View File

@@ -1,64 +0,0 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
# Enhanced addopts for better development experience
addopts =
-v
--tb=short
--strict-markers
--strict-config
--color=yes
--durations=10
--showlocals
-ra
--cov=app
--cov=models
--cov=middleware
--cov-report=term-missing
--cov-report=html:htmlcov
--cov-fail-under=80
# Test discovery and execution settings
minversion = 6.0
# Markers for your specific test organization
markers =
unit: marks tests as unit tests - fast, isolated components
integration: marks tests as integration tests - multiple components working together
system: marks tests as system tests - full application behavior
e2e: marks tests as end-to-end tests - complete user workflows
slow: marks tests as slow running tests (deselect with '-m "not slow"')
performance: marks tests as performance and load tests
auth: marks tests as authentication and authorization tests
products: marks tests as product management functionality
inventory: marks tests as inventory and inventory management
vendors: marks tests as vendor management functionality
admin: marks tests as admin functionality and permissions
marketplace: marks tests as marketplace import functionality
stats: marks tests as statistics and reporting
database: marks tests as tests that require database operations
external: marks tests as tests that require external services
api: marks tests as API endpoint tests
security: marks tests as security-related tests
ci: marks tests as tests that should only run in CI
dev: marks tests as development-specific tests
# Test filtering shortcuts
filterwarnings =
ignore::UserWarning
ignore::DeprecationWarning
ignore::PendingDeprecationWarning
ignore::sqlalchemy.exc.SAWarning
# Timeout settings
timeout = 300
timeout_method = thread
# Additional logging configuration
log_cli = true
log_cli_level = INFO
log_cli_format = %(asctime)s [%(levelname)8s] %(name)s: %(message)s
log_cli_date_format = %Y-%m-%d %H:%M:%S

View File

@@ -381,6 +381,25 @@ class ArchitectureValidator:
print("⏭️ Not an admin template, skipping extends check") print("⏭️ Not an admin template, skipping extends check")
return return
# Check for standalone marker in template (first 5 lines)
# Supports: {# standalone #}, {# noqa: TPL-001 #}, <!-- standalone -->
first_lines = "\n".join(lines[:5]).lower()
if "standalone" in first_lines or "noqa: tpl-001" in first_lines:
print("⏭️ Template marked as standalone, skipping extends check")
return
# Check exclusion patterns for TPL-001
# These are templates that intentionally don't extend admin/base.html
tpl_001_exclusions = [
"login.html", # Standalone login page
"errors/", # Error pages extend errors/base.html
"test-", # Test templates
]
for exclusion in tpl_001_exclusions:
if exclusion in file_path_str:
print(f"⏭️ Template matches exclusion pattern '{exclusion}', skipping")
return
# TPL-001: Check for extends # TPL-001: Check for extends
has_extends = any( has_extends = any(
"{% extends" in line and "admin/base.html" in line for line in lines "{% extends" in line and "admin/base.html" in line for line in lines
@@ -395,7 +414,7 @@ class ArchitectureValidator:
line_number=1, line_number=1,
message="Admin template does not extend admin/base.html", message="Admin template does not extend admin/base.html",
context=file_path.name, context=file_path.name,
suggestion="Add {% extends 'admin/base.html' %} at the top", suggestion="Add {% extends 'admin/base.html' %} at the top, or add {# standalone #} if intentional",
) )
def _validate_api_endpoints(self, target_path: Path): def _validate_api_endpoints(self, target_path: Path):
@@ -535,26 +554,44 @@ class ArchitectureValidator:
def _check_endpoint_authentication( def _check_endpoint_authentication(
self, file_path: Path, content: str, lines: list[str] self, file_path: Path, content: str, lines: list[str]
): ):
"""API-004: Check authentication on endpoints""" """API-004: Check authentication on endpoints
Automatically skips:
- Auth endpoint files (*/auth.py) - login/logout are intentionally public
- Endpoints marked with '# public' comment
"""
rule = self._get_rule("API-004") rule = self._get_rule("API-004")
if not rule: if not rule:
return return
# Skip auth endpoint files entirely - they are intentionally public
file_path_str = str(file_path)
if file_path_str.endswith("/auth.py") or file_path_str.endswith("\\auth.py"):
return
# This is a warning-level check # This is a warning-level check
# Look for endpoints without Depends(get_current_*) # Look for endpoints without Depends(get_current_*)
for i, line in enumerate(lines, 1): for i, line in enumerate(lines, 1):
if "@router." in line and ( if "@router." in line and (
"post" in line or "put" in line or "delete" in line "post" in line or "put" in line or "delete" in line
): ):
# Check next 5 lines for auth # Check next 15 lines for auth or public marker
# (increased from 5 to handle multi-line decorators and long function signatures)
has_auth = False has_auth = False
for j in range(i, min(i + 5, len(lines))): is_public = False
if "Depends(get_current_" in lines[j]: context_lines = lines[i - 1 : i + 15] # Include line before decorator
for ctx_line in context_lines:
if "Depends(get_current_" in ctx_line:
has_auth = True has_auth = True
break break
# Check for public endpoint markers
if "# public" in ctx_line.lower() or "# noqa: api-004" in ctx_line.lower():
is_public = True
break
if not has_auth and "include_in_schema=False" not in " ".join( if not has_auth and not is_public and "include_in_schema=False" not in " ".join(
lines[i : i + 5] lines[i : i + 15]
): ):
self._add_violation( self._add_violation(
rule_id="API-004", rule_id="API-004",
@@ -564,7 +601,7 @@ class ArchitectureValidator:
line_number=i, line_number=i,
message="Endpoint may be missing authentication", message="Endpoint may be missing authentication",
context=line.strip(), context=line.strip(),
suggestion="Add Depends(get_current_user) or similar if endpoint should be protected", suggestion="Add Depends(get_current_user) or mark as '# public' if intentionally unauthenticated",
) )
def _validate_service_layer(self, target_path: Path): def _validate_service_layer(self, target_path: Path):
@@ -816,14 +853,37 @@ class ArchitectureValidator:
template_files = list(target_path.glob("app/templates/admin/**/*.html")) template_files = list(target_path.glob("app/templates/admin/**/*.html"))
self.result.files_checked += len(template_files) self.result.files_checked += len(template_files)
# TPL-001 exclusion patterns
tpl_001_exclusions = [
"login.html", # Standalone login page
"errors/", # Error pages extend errors/base.html
"test-", # Test templates
]
for file_path in template_files: for file_path in template_files:
# Skip base template and partials # Skip base template and partials
if "base.html" in file_path.name or "partials" in str(file_path): if "base.html" in file_path.name or "partials" in str(file_path):
continue continue
file_path_str = str(file_path)
# Check exclusion patterns
skip = False
for exclusion in tpl_001_exclusions:
if exclusion in file_path_str:
skip = True
break
if skip:
continue
content = file_path.read_text() content = file_path.read_text()
lines = content.split("\n") lines = content.split("\n")
# Check for standalone marker in template (first 5 lines)
first_lines = "\n".join(lines[:5]).lower()
if "standalone" in first_lines or "noqa: tpl-001" in first_lines:
continue
# TPL-001: Check for extends # TPL-001: Check for extends
has_extends = any( has_extends = any(
"{% extends" in line and "admin/base.html" in line for line in lines "{% extends" in line and "admin/base.html" in line for line in lines
@@ -838,7 +898,7 @@ class ArchitectureValidator:
line_number=1, line_number=1,
message="Admin template does not extend admin/base.html", message="Admin template does not extend admin/base.html",
context=file_path.name, context=file_path.name,
suggestion="Add {% extends 'admin/base.html' %} at the top", suggestion="Add {% extends 'admin/base.html' %} at the top, or add {# standalone #} if intentional",
) )
def _get_rule(self, rule_id: str) -> dict[str, Any]: def _get_rule(self, rule_id: str) -> dict[str, Any]:

View File

@@ -1,4 +1,254 @@
/* Tailwind CSS source file for admin panel */ /* Tailwind CSS v4 - Admin Panel Styles */
@tailwind base; /* Configuration is CSS-first in v4 */
@tailwind components;
@tailwind utilities; @import "tailwindcss";
/* Source paths for content scanning */
@source "../../js/**/*.js";
@source "../../../app/templates/**/*.html";
/* Custom theme configuration */
@theme {
/* Custom gray palette (Windmill Dashboard) */
--color-gray-50: #f9fafb;
--color-gray-100: #f4f5f7;
--color-gray-200: #e5e7eb;
--color-gray-300: #d5d6d7;
--color-gray-400: #9e9e9e;
--color-gray-500: #707275;
--color-gray-600: #4c4f52;
--color-gray-700: #24262d;
--color-gray-800: #1a1c23;
--color-gray-900: #121317;
/* Custom purple palette (Windmill Dashboard) */
--color-purple-50: #f6f5ff;
--color-purple-100: #edebfe;
--color-purple-200: #dcd7fe;
--color-purple-300: #cabffd;
--color-purple-400: #ac94fa;
--color-purple-500: #9061f9;
--color-purple-600: #7e3af2;
--color-purple-700: #6c2bd9;
--color-purple-800: #5521b5;
--color-purple-900: #4a1d96;
/* Custom orange palette */
--color-orange-50: #fff8f1;
--color-orange-100: #feecdc;
--color-orange-200: #fcd9bd;
--color-orange-300: #fdba8c;
--color-orange-400: #ff8a4c;
--color-orange-500: #ff5a1f;
--color-orange-600: #d03801;
--color-orange-700: #b43403;
--color-orange-800: #8a2c0d;
--color-orange-900: #771d1d;
/* Custom green palette */
--color-green-50: #f3faf7;
--color-green-100: #def7ec;
--color-green-200: #bcf0da;
--color-green-300: #84e1bc;
--color-green-400: #31c48d;
--color-green-500: #0e9f6e;
--color-green-600: #057a55;
--color-green-700: #046c4e;
--color-green-800: #03543f;
--color-green-900: #014737;
/* Custom red palette */
--color-red-50: #fdf2f2;
--color-red-100: #fde8e8;
--color-red-200: #fbd5d5;
--color-red-300: #f8b4b4;
--color-red-400: #f98080;
--color-red-500: #f05252;
--color-red-600: #e02424;
--color-red-700: #c81e1e;
--color-red-800: #9b1c1c;
--color-red-900: #771d1d;
/* Custom yellow palette */
--color-yellow-50: #fdfdea;
--color-yellow-100: #fdf6b2;
--color-yellow-200: #fce96a;
--color-yellow-300: #faca15;
--color-yellow-400: #e3a008;
--color-yellow-500: #c27803;
--color-yellow-600: #9f580a;
--color-yellow-700: #8e4b10;
--color-yellow-800: #723b13;
--color-yellow-900: #633112;
/* Custom teal palette */
--color-teal-50: #edfafa;
--color-teal-100: #d5f5f6;
--color-teal-200: #afecef;
--color-teal-300: #7edce2;
--color-teal-400: #16bdca;
--color-teal-500: #0694a2;
--color-teal-600: #047481;
--color-teal-700: #036672;
--color-teal-800: #05505c;
--color-teal-900: #014451;
/* Custom blue palette */
--color-blue-50: #ebf5ff;
--color-blue-100: #e1effe;
--color-blue-200: #c3ddfd;
--color-blue-300: #a4cafe;
--color-blue-400: #76a9fa;
--color-blue-500: #3f83f8;
--color-blue-600: #1c64f2;
--color-blue-700: #1a56db;
--color-blue-800: #1e429f;
--color-blue-900: #233876;
/* Custom indigo palette */
--color-indigo-50: #f0f5ff;
--color-indigo-100: #e5edff;
--color-indigo-200: #cddbfe;
--color-indigo-300: #b4c6fc;
--color-indigo-400: #8da2fb;
--color-indigo-500: #6875f5;
--color-indigo-600: #5850ec;
--color-indigo-700: #5145cd;
--color-indigo-800: #42389d;
--color-indigo-900: #362f78;
/* Custom pink palette */
--color-pink-50: #fdf2f8;
--color-pink-100: #fce8f3;
--color-pink-200: #fad1e8;
--color-pink-300: #f8b4d9;
--color-pink-400: #f17eb8;
--color-pink-500: #e74694;
--color-pink-600: #d61f69;
--color-pink-700: #bf125d;
--color-pink-800: #99154b;
--color-pink-900: #751a3d;
/* Cool gray palette */
--color-cool-gray-50: #fbfdfe;
--color-cool-gray-100: #f1f5f9;
--color-cool-gray-200: #e2e8f0;
--color-cool-gray-300: #cfd8e3;
--color-cool-gray-400: #97a6ba;
--color-cool-gray-500: #64748b;
--color-cool-gray-600: #475569;
--color-cool-gray-700: #364152;
--color-cool-gray-800: #27303f;
--color-cool-gray-900: #1a202e;
/* Font family */
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
/* Custom max-height */
--spacing-xl: 36rem;
}
/* Dark mode variant - uses class on html element */
@variant dark (&:where(.dark, .dark *));
/* Custom utilities layer */
@layer utilities {
/* Shadow outline utilities for focus states */
.shadow-outline-gray {
box-shadow: 0 0 0 3px hsla(220, 9%, 83%, 0.45);
}
.shadow-outline-purple {
box-shadow: 0 0 0 3px hsla(262, 97%, 81%, 0.45);
}
.shadow-outline-red {
box-shadow: 0 0 0 3px hsla(0, 91%, 85%, 0.45);
}
.shadow-outline-orange {
box-shadow: 0 0 0 3px hsla(22, 97%, 77%, 0.45);
}
.shadow-outline-green {
box-shadow: 0 0 0 3px hsla(152, 68%, 70%, 0.45);
}
.shadow-outline-blue {
box-shadow: 0 0 0 3px hsla(215, 96%, 81%, 0.45);
}
.shadow-outline-yellow {
box-shadow: 0 0 0 3px hsla(46, 97%, 65%, 0.45);
}
.shadow-outline-teal {
box-shadow: 0 0 0 3px hsla(182, 68%, 69%, 0.45);
}
.shadow-outline-indigo {
box-shadow: 0 0 0 3px hsla(226, 95%, 85%, 0.45);
}
.shadow-outline-pink {
box-shadow: 0 0 0 3px hsla(330, 89%, 83%, 0.45);
}
}
/* Component layer for form styles (replaces @tailwindcss/custom-forms) */
@layer components {
/* Form input styles */
.form-input,
.form-textarea,
.form-select,
.form-multiselect {
@apply block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm transition-colors;
@apply focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500;
@apply placeholder:text-gray-400;
}
/* Dark mode form inputs */
.dark .form-input,
.dark .form-textarea,
.dark .form-select,
.dark .form-multiselect {
@apply border-gray-600 bg-gray-700 text-gray-200;
@apply focus:border-purple-400 focus:ring-purple-400;
@apply placeholder:text-gray-500;
}
/* Checkbox styles */
.form-checkbox {
@apply h-4 w-4 rounded border-gray-300 text-purple-600 transition-colors;
@apply focus:ring-2 focus:ring-purple-500 focus:ring-offset-2;
}
.dark .form-checkbox {
@apply border-gray-600 bg-gray-700;
@apply focus:ring-purple-400 focus:ring-offset-gray-800;
}
/* Radio styles */
.form-radio {
@apply h-4 w-4 border-gray-300 text-purple-600 transition-colors;
@apply focus:ring-2 focus:ring-purple-500 focus:ring-offset-2;
}
.dark .form-radio {
@apply border-gray-600 bg-gray-700;
@apply focus:ring-purple-400 focus:ring-offset-gray-800;
}
}
/* Base layer for default styles */
@layer base {
/* Default body styles */
body {
@apply bg-gray-50 text-gray-900 antialiased;
}
/* Dark mode body */
.dark body {
@apply bg-gray-900 text-gray-200;
}
/* Focus visible for accessibility */
:focus-visible {
@apply outline-2 outline-offset-2 outline-purple-500;
}
.dark :focus-visible {
@apply outline-purple-400;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,286 @@
/* static/platform/css/tailwind.css */
/* Tailwind CSS v4 source file for Platform frontend */
/* CSS-first configuration - no tailwind.config.js needed */
@import "tailwindcss";
/* Content sources for tree-shaking */
@source "../../../app/templates/platform/**/*.html";
@source "../js/**/*.js";
/* ==========================================================================
THEME CONFIGURATION
Migrated from legacy tailwind.config.js with Windmill color palette
========================================================================== */
@theme {
/* Font Family - Inter as primary sans-serif */
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
/* ==========================================================================
COLOR PALETTE
Custom colors from Windmill/Flowbite design system
========================================================================== */
/* Gray - Custom darker palette */
--color-gray-50: #f9fafb;
--color-gray-100: #f4f5f7;
--color-gray-200: #e5e7eb;
--color-gray-300: #d5d6d7;
--color-gray-400: #9e9e9e;
--color-gray-500: #707275;
--color-gray-600: #4c4f52;
--color-gray-700: #24262d;
--color-gray-800: #1a1c23;
--color-gray-900: #121317;
/* Cool Gray */
--color-cool-gray-50: #fbfdfe;
--color-cool-gray-100: #f1f5f9;
--color-cool-gray-200: #e2e8f0;
--color-cool-gray-300: #cfd8e3;
--color-cool-gray-400: #97a6ba;
--color-cool-gray-500: #64748b;
--color-cool-gray-600: #475569;
--color-cool-gray-700: #364152;
--color-cool-gray-800: #27303f;
--color-cool-gray-900: #1a202e;
/* Red */
--color-red-50: #fdf2f2;
--color-red-100: #fde8e8;
--color-red-200: #fbd5d5;
--color-red-300: #f8b4b4;
--color-red-400: #f98080;
--color-red-500: #f05252;
--color-red-600: #e02424;
--color-red-700: #c81e1e;
--color-red-800: #9b1c1c;
--color-red-900: #771d1d;
/* Orange */
--color-orange-50: #fff8f1;
--color-orange-100: #feecdc;
--color-orange-200: #fcd9bd;
--color-orange-300: #fdba8c;
--color-orange-400: #ff8a4c;
--color-orange-500: #ff5a1f;
--color-orange-600: #d03801;
--color-orange-700: #b43403;
--color-orange-800: #8a2c0d;
--color-orange-900: #771d1d;
/* Yellow */
--color-yellow-50: #fdfdea;
--color-yellow-100: #fdf6b2;
--color-yellow-200: #fce96a;
--color-yellow-300: #faca15;
--color-yellow-400: #e3a008;
--color-yellow-500: #c27803;
--color-yellow-600: #9f580a;
--color-yellow-700: #8e4b10;
--color-yellow-800: #723b13;
--color-yellow-900: #633112;
/* Green */
--color-green-50: #f3faf7;
--color-green-100: #def7ec;
--color-green-200: #bcf0da;
--color-green-300: #84e1bc;
--color-green-400: #31c48d;
--color-green-500: #0e9f6e;
--color-green-600: #057a55;
--color-green-700: #046c4e;
--color-green-800: #03543f;
--color-green-900: #014737;
/* Teal */
--color-teal-50: #edfafa;
--color-teal-100: #d5f5f6;
--color-teal-200: #afecef;
--color-teal-300: #7edce2;
--color-teal-400: #16bdca;
--color-teal-500: #0694a2;
--color-teal-600: #047481;
--color-teal-700: #036672;
--color-teal-800: #05505c;
--color-teal-900: #014451;
/* Blue */
--color-blue-50: #ebf5ff;
--color-blue-100: #e1effe;
--color-blue-200: #c3ddfd;
--color-blue-300: #a4cafe;
--color-blue-400: #76a9fa;
--color-blue-500: #3f83f8;
--color-blue-600: #1c64f2;
--color-blue-700: #1a56db;
--color-blue-800: #1e429f;
--color-blue-900: #233876;
/* Indigo */
--color-indigo-50: #f0f5ff;
--color-indigo-100: #e5edff;
--color-indigo-200: #cddbfe;
--color-indigo-300: #b4c6fc;
--color-indigo-400: #8da2fb;
--color-indigo-500: #6875f5;
--color-indigo-600: #5850ec;
--color-indigo-700: #5145cd;
--color-indigo-800: #42389d;
--color-indigo-900: #362f78;
/* Purple */
--color-purple-50: #f6f5ff;
--color-purple-100: #edebfe;
--color-purple-200: #dcd7fe;
--color-purple-300: #cabffd;
--color-purple-400: #ac94fa;
--color-purple-500: #9061f9;
--color-purple-600: #7e3af2;
--color-purple-700: #6c2bd9;
--color-purple-800: #5521b5;
--color-purple-900: #4a1d96;
/* Pink */
--color-pink-50: #fdf2f8;
--color-pink-100: #fce8f3;
--color-pink-200: #fad1e8;
--color-pink-300: #f8b4d9;
--color-pink-400: #f17eb8;
--color-pink-500: #e74694;
--color-pink-600: #d61f69;
--color-pink-700: #bf125d;
--color-pink-800: #99154b;
--color-pink-900: #751a3d;
/* Extended max-height */
--max-height-xl: 36rem;
}
/* ==========================================================================
DARK MODE VARIANT
Matches the .dark class pattern used in templates
========================================================================== */
@variant dark (&:where(.dark, .dark *));
/* ==========================================================================
CUSTOM UTILITIES
Shadow outline utilities (migrated from plugin)
========================================================================== */
@utility shadow-outline-gray {
box-shadow: 0 0 0 3px hsla(0, 0%, 84%, 0.45);
}
@utility shadow-outline-red {
box-shadow: 0 0 0 3px hsla(0, 90%, 84%, 0.45);
}
@utility shadow-outline-orange {
box-shadow: 0 0 0 3px hsla(24, 97%, 77%, 0.45);
}
@utility shadow-outline-yellow {
box-shadow: 0 0 0 3px hsla(45, 97%, 53%, 0.45);
}
@utility shadow-outline-green {
box-shadow: 0 0 0 3px hsla(156, 70%, 70%, 0.45);
}
@utility shadow-outline-teal {
box-shadow: 0 0 0 3px hsla(183, 71%, 69%, 0.45);
}
@utility shadow-outline-blue {
box-shadow: 0 0 0 3px hsla(214, 95%, 84%, 0.45);
}
@utility shadow-outline-indigo {
box-shadow: 0 0 0 3px hsla(227, 94%, 85%, 0.45);
}
@utility shadow-outline-purple {
box-shadow: 0 0 0 3px hsla(259, 97%, 87%, 0.45);
}
@utility shadow-outline-pink {
box-shadow: 0 0 0 3px hsla(327, 87%, 84%, 0.45);
}
/* ==========================================================================
FORM STYLES
Custom form placeholder styling (replaces @tailwindcss/custom-forms)
========================================================================== */
@layer base {
input::placeholder,
textarea::placeholder {
color: var(--color-gray-400);
}
}
/* ==========================================================================
PLATFORM-SPECIFIC COMPONENTS
========================================================================== */
@layer components {
/* Gradient backgrounds */
.gradient-primary {
background: linear-gradient(135deg, var(--color-indigo-500) 0%, var(--color-purple-500) 100%);
}
.gradient-accent {
background: linear-gradient(135deg, var(--color-purple-500) 0%, var(--color-pink-500) 100%);
}
/* Primary button */
.btn-primary {
background-color: var(--color-indigo-500);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
transition: all 0.2s;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* Secondary button */
.btn-secondary {
background-color: var(--color-gray-100);
color: var(--color-gray-700);
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
transition: all 0.2s;
}
.dark .btn-secondary {
background-color: var(--color-gray-700);
color: var(--color-gray-100);
}
.btn-secondary:hover {
background-color: var(--color-gray-200);
}
.dark .btn-secondary:hover {
background-color: var(--color-gray-600);
}
/* Card hover effect */
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,274 @@
/* Tailwind CSS v4 - Shop Frontend Styles */
/* Configuration is CSS-first in v4 */
@import "tailwindcss";
/* Source paths for content scanning */
@source "../../js/**/*.js";
@source "../../../app/templates/shop/**/*.html";
@source "../../../app/templates/shared/**/*.html";
/* Custom theme configuration */
@theme {
/* Custom gray palette (Windmill Dashboard) */
--color-gray-50: #f9fafb;
--color-gray-100: #f4f5f7;
--color-gray-200: #e5e7eb;
--color-gray-300: #d5d6d7;
--color-gray-400: #9e9e9e;
--color-gray-500: #707275;
--color-gray-600: #4c4f52;
--color-gray-700: #24262d;
--color-gray-800: #1a1c23;
--color-gray-900: #121317;
/* Custom purple palette (Windmill Dashboard) */
--color-purple-50: #f6f5ff;
--color-purple-100: #edebfe;
--color-purple-200: #dcd7fe;
--color-purple-300: #cabffd;
--color-purple-400: #ac94fa;
--color-purple-500: #9061f9;
--color-purple-600: #7e3af2;
--color-purple-700: #6c2bd9;
--color-purple-800: #5521b5;
--color-purple-900: #4a1d96;
/* Custom orange palette */
--color-orange-50: #fff8f1;
--color-orange-100: #feecdc;
--color-orange-200: #fcd9bd;
--color-orange-300: #fdba8c;
--color-orange-400: #ff8a4c;
--color-orange-500: #ff5a1f;
--color-orange-600: #d03801;
--color-orange-700: #b43403;
--color-orange-800: #8a2c0d;
--color-orange-900: #771d1d;
/* Custom green palette */
--color-green-50: #f3faf7;
--color-green-100: #def7ec;
--color-green-200: #bcf0da;
--color-green-300: #84e1bc;
--color-green-400: #31c48d;
--color-green-500: #0e9f6e;
--color-green-600: #057a55;
--color-green-700: #046c4e;
--color-green-800: #03543f;
--color-green-900: #014737;
/* Custom red palette */
--color-red-50: #fdf2f2;
--color-red-100: #fde8e8;
--color-red-200: #fbd5d5;
--color-red-300: #f8b4b4;
--color-red-400: #f98080;
--color-red-500: #f05252;
--color-red-600: #e02424;
--color-red-700: #c81e1e;
--color-red-800: #9b1c1c;
--color-red-900: #771d1d;
/* Custom yellow palette */
--color-yellow-50: #fdfdea;
--color-yellow-100: #fdf6b2;
--color-yellow-200: #fce96a;
--color-yellow-300: #faca15;
--color-yellow-400: #e3a008;
--color-yellow-500: #c27803;
--color-yellow-600: #9f580a;
--color-yellow-700: #8e4b10;
--color-yellow-800: #723b13;
--color-yellow-900: #633112;
/* Custom teal palette */
--color-teal-50: #edfafa;
--color-teal-100: #d5f5f6;
--color-teal-200: #afecef;
--color-teal-300: #7edce2;
--color-teal-400: #16bdca;
--color-teal-500: #0694a2;
--color-teal-600: #047481;
--color-teal-700: #036672;
--color-teal-800: #05505c;
--color-teal-900: #014451;
/* Custom blue palette */
--color-blue-50: #ebf5ff;
--color-blue-100: #e1effe;
--color-blue-200: #c3ddfd;
--color-blue-300: #a4cafe;
--color-blue-400: #76a9fa;
--color-blue-500: #3f83f8;
--color-blue-600: #1c64f2;
--color-blue-700: #1a56db;
--color-blue-800: #1e429f;
--color-blue-900: #233876;
/* Custom indigo palette */
--color-indigo-50: #f0f5ff;
--color-indigo-100: #e5edff;
--color-indigo-200: #cddbfe;
--color-indigo-300: #b4c6fc;
--color-indigo-400: #8da2fb;
--color-indigo-500: #6875f5;
--color-indigo-600: #5850ec;
--color-indigo-700: #5145cd;
--color-indigo-800: #42389d;
--color-indigo-900: #362f78;
/* Custom pink palette */
--color-pink-50: #fdf2f8;
--color-pink-100: #fce8f3;
--color-pink-200: #fad1e8;
--color-pink-300: #f8b4d9;
--color-pink-400: #f17eb8;
--color-pink-500: #e74694;
--color-pink-600: #d61f69;
--color-pink-700: #bf125d;
--color-pink-800: #99154b;
--color-pink-900: #751a3d;
/* Cool gray palette */
--color-cool-gray-50: #fbfdfe;
--color-cool-gray-100: #f1f5f9;
--color-cool-gray-200: #e2e8f0;
--color-cool-gray-300: #cfd8e3;
--color-cool-gray-400: #97a6ba;
--color-cool-gray-500: #64748b;
--color-cool-gray-600: #475569;
--color-cool-gray-700: #364152;
--color-cool-gray-800: #27303f;
--color-cool-gray-900: #1a202e;
/* Font family */
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
/* Custom max-height */
--spacing-xl: 36rem;
}
/* Dark mode variant - uses class on html element */
@variant dark (&:where(.dark, .dark *));
/* Custom utilities layer */
@layer utilities {
/* Shadow outline utilities for focus states */
.shadow-outline-gray {
box-shadow: 0 0 0 3px hsla(220, 9%, 83%, 0.45);
}
.shadow-outline-purple {
box-shadow: 0 0 0 3px hsla(262, 97%, 81%, 0.45);
}
.shadow-outline-red {
box-shadow: 0 0 0 3px hsla(0, 91%, 85%, 0.45);
}
.shadow-outline-orange {
box-shadow: 0 0 0 3px hsla(22, 97%, 77%, 0.45);
}
.shadow-outline-green {
box-shadow: 0 0 0 3px hsla(152, 68%, 70%, 0.45);
}
.shadow-outline-blue {
box-shadow: 0 0 0 3px hsla(215, 96%, 81%, 0.45);
}
.shadow-outline-yellow {
box-shadow: 0 0 0 3px hsla(46, 97%, 65%, 0.45);
}
.shadow-outline-teal {
box-shadow: 0 0 0 3px hsla(182, 68%, 69%, 0.45);
}
.shadow-outline-indigo {
box-shadow: 0 0 0 3px hsla(226, 95%, 85%, 0.45);
}
.shadow-outline-pink {
box-shadow: 0 0 0 3px hsla(330, 89%, 83%, 0.45);
}
}
/* Shop-specific component styles */
@layer components {
/* Form input styles */
.form-input,
.form-textarea,
.form-select,
.form-multiselect {
@apply block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm transition-colors;
@apply focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500;
@apply placeholder:text-gray-400;
}
/* Dark mode form inputs */
.dark .form-input,
.dark .form-textarea,
.dark .form-select,
.dark .form-multiselect {
@apply border-gray-600 bg-gray-700 text-gray-200;
@apply focus:border-purple-400 focus:ring-purple-400;
@apply placeholder:text-gray-500;
}
/* Checkbox styles */
.form-checkbox {
@apply h-4 w-4 rounded border-gray-300 text-purple-600 transition-colors;
@apply focus:ring-2 focus:ring-purple-500 focus:ring-offset-2;
}
.dark .form-checkbox {
@apply border-gray-600 bg-gray-700;
@apply focus:ring-purple-400 focus:ring-offset-gray-800;
}
/* Radio styles */
.form-radio {
@apply h-4 w-4 border-gray-300 text-purple-600 transition-colors;
@apply focus:ring-2 focus:ring-purple-500 focus:ring-offset-2;
}
.dark .form-radio {
@apply border-gray-600 bg-gray-700;
@apply focus:ring-purple-400 focus:ring-offset-gray-800;
}
/* Shop-specific button using CSS variable for vendor theming */
.btn-shop-primary {
background-color: var(--color-primary, #7e3af2);
@apply text-white px-4 py-2 rounded-lg font-medium transition-all;
}
.btn-shop-primary:hover {
filter: brightness(0.9);
}
/* Product card hover effect */
.product-card {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden transition-all duration-300;
}
.product-card:hover {
@apply shadow-xl -translate-y-1;
}
}
/* Base layer for default styles */
@layer base {
/* Default body styles */
body {
@apply bg-gray-50 text-gray-900 antialiased;
}
/* Dark mode body */
.dark body {
@apply bg-gray-900 text-gray-200;
}
/* Focus visible for accessibility */
:focus-visible {
@apply outline-2 outline-offset-2 outline-purple-500;
}
.dark :focus-visible {
@apply outline-purple-400;
}
}

File diff suppressed because it is too large Load Diff

254
static/vendor/css/tailwind.css vendored Normal file
View File

@@ -0,0 +1,254 @@
/* Tailwind CSS v4 - Vendor Panel Styles */
/* Configuration is CSS-first in v4 */
@import "tailwindcss";
/* Source paths for content scanning */
@source "../../js/**/*.js";
@source "../../../app/templates/vendor/**/*.html";
/* Custom theme configuration */
@theme {
/* Custom gray palette (Windmill Dashboard) */
--color-gray-50: #f9fafb;
--color-gray-100: #f4f5f7;
--color-gray-200: #e5e7eb;
--color-gray-300: #d5d6d7;
--color-gray-400: #9e9e9e;
--color-gray-500: #707275;
--color-gray-600: #4c4f52;
--color-gray-700: #24262d;
--color-gray-800: #1a1c23;
--color-gray-900: #121317;
/* Custom purple palette (Windmill Dashboard) */
--color-purple-50: #f6f5ff;
--color-purple-100: #edebfe;
--color-purple-200: #dcd7fe;
--color-purple-300: #cabffd;
--color-purple-400: #ac94fa;
--color-purple-500: #9061f9;
--color-purple-600: #7e3af2;
--color-purple-700: #6c2bd9;
--color-purple-800: #5521b5;
--color-purple-900: #4a1d96;
/* Custom orange palette */
--color-orange-50: #fff8f1;
--color-orange-100: #feecdc;
--color-orange-200: #fcd9bd;
--color-orange-300: #fdba8c;
--color-orange-400: #ff8a4c;
--color-orange-500: #ff5a1f;
--color-orange-600: #d03801;
--color-orange-700: #b43403;
--color-orange-800: #8a2c0d;
--color-orange-900: #771d1d;
/* Custom green palette */
--color-green-50: #f3faf7;
--color-green-100: #def7ec;
--color-green-200: #bcf0da;
--color-green-300: #84e1bc;
--color-green-400: #31c48d;
--color-green-500: #0e9f6e;
--color-green-600: #057a55;
--color-green-700: #046c4e;
--color-green-800: #03543f;
--color-green-900: #014737;
/* Custom red palette */
--color-red-50: #fdf2f2;
--color-red-100: #fde8e8;
--color-red-200: #fbd5d5;
--color-red-300: #f8b4b4;
--color-red-400: #f98080;
--color-red-500: #f05252;
--color-red-600: #e02424;
--color-red-700: #c81e1e;
--color-red-800: #9b1c1c;
--color-red-900: #771d1d;
/* Custom yellow palette */
--color-yellow-50: #fdfdea;
--color-yellow-100: #fdf6b2;
--color-yellow-200: #fce96a;
--color-yellow-300: #faca15;
--color-yellow-400: #e3a008;
--color-yellow-500: #c27803;
--color-yellow-600: #9f580a;
--color-yellow-700: #8e4b10;
--color-yellow-800: #723b13;
--color-yellow-900: #633112;
/* Custom teal palette */
--color-teal-50: #edfafa;
--color-teal-100: #d5f5f6;
--color-teal-200: #afecef;
--color-teal-300: #7edce2;
--color-teal-400: #16bdca;
--color-teal-500: #0694a2;
--color-teal-600: #047481;
--color-teal-700: #036672;
--color-teal-800: #05505c;
--color-teal-900: #014451;
/* Custom blue palette */
--color-blue-50: #ebf5ff;
--color-blue-100: #e1effe;
--color-blue-200: #c3ddfd;
--color-blue-300: #a4cafe;
--color-blue-400: #76a9fa;
--color-blue-500: #3f83f8;
--color-blue-600: #1c64f2;
--color-blue-700: #1a56db;
--color-blue-800: #1e429f;
--color-blue-900: #233876;
/* Custom indigo palette */
--color-indigo-50: #f0f5ff;
--color-indigo-100: #e5edff;
--color-indigo-200: #cddbfe;
--color-indigo-300: #b4c6fc;
--color-indigo-400: #8da2fb;
--color-indigo-500: #6875f5;
--color-indigo-600: #5850ec;
--color-indigo-700: #5145cd;
--color-indigo-800: #42389d;
--color-indigo-900: #362f78;
/* Custom pink palette */
--color-pink-50: #fdf2f8;
--color-pink-100: #fce8f3;
--color-pink-200: #fad1e8;
--color-pink-300: #f8b4d9;
--color-pink-400: #f17eb8;
--color-pink-500: #e74694;
--color-pink-600: #d61f69;
--color-pink-700: #bf125d;
--color-pink-800: #99154b;
--color-pink-900: #751a3d;
/* Cool gray palette */
--color-cool-gray-50: #fbfdfe;
--color-cool-gray-100: #f1f5f9;
--color-cool-gray-200: #e2e8f0;
--color-cool-gray-300: #cfd8e3;
--color-cool-gray-400: #97a6ba;
--color-cool-gray-500: #64748b;
--color-cool-gray-600: #475569;
--color-cool-gray-700: #364152;
--color-cool-gray-800: #27303f;
--color-cool-gray-900: #1a202e;
/* Font family */
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
/* Custom max-height */
--spacing-xl: 36rem;
}
/* Dark mode variant - uses class on html element */
@variant dark (&:where(.dark, .dark *));
/* Custom utilities layer */
@layer utilities {
/* Shadow outline utilities for focus states */
.shadow-outline-gray {
box-shadow: 0 0 0 3px hsla(220, 9%, 83%, 0.45);
}
.shadow-outline-purple {
box-shadow: 0 0 0 3px hsla(262, 97%, 81%, 0.45);
}
.shadow-outline-red {
box-shadow: 0 0 0 3px hsla(0, 91%, 85%, 0.45);
}
.shadow-outline-orange {
box-shadow: 0 0 0 3px hsla(22, 97%, 77%, 0.45);
}
.shadow-outline-green {
box-shadow: 0 0 0 3px hsla(152, 68%, 70%, 0.45);
}
.shadow-outline-blue {
box-shadow: 0 0 0 3px hsla(215, 96%, 81%, 0.45);
}
.shadow-outline-yellow {
box-shadow: 0 0 0 3px hsla(46, 97%, 65%, 0.45);
}
.shadow-outline-teal {
box-shadow: 0 0 0 3px hsla(182, 68%, 69%, 0.45);
}
.shadow-outline-indigo {
box-shadow: 0 0 0 3px hsla(226, 95%, 85%, 0.45);
}
.shadow-outline-pink {
box-shadow: 0 0 0 3px hsla(330, 89%, 83%, 0.45);
}
}
/* Component layer for form styles (replaces @tailwindcss/custom-forms) */
@layer components {
/* Form input styles */
.form-input,
.form-textarea,
.form-select,
.form-multiselect {
@apply block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm transition-colors;
@apply focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500;
@apply placeholder:text-gray-400;
}
/* Dark mode form inputs */
.dark .form-input,
.dark .form-textarea,
.dark .form-select,
.dark .form-multiselect {
@apply border-gray-600 bg-gray-700 text-gray-200;
@apply focus:border-purple-400 focus:ring-purple-400;
@apply placeholder:text-gray-500;
}
/* Checkbox styles */
.form-checkbox {
@apply h-4 w-4 rounded border-gray-300 text-purple-600 transition-colors;
@apply focus:ring-2 focus:ring-purple-500 focus:ring-offset-2;
}
.dark .form-checkbox {
@apply border-gray-600 bg-gray-700;
@apply focus:ring-purple-400 focus:ring-offset-gray-800;
}
/* Radio styles */
.form-radio {
@apply h-4 w-4 border-gray-300 text-purple-600 transition-colors;
@apply focus:ring-2 focus:ring-purple-500 focus:ring-offset-2;
}
.dark .form-radio {
@apply border-gray-600 bg-gray-700;
@apply focus:ring-purple-400 focus:ring-offset-gray-800;
}
}
/* Base layer for default styles */
@layer base {
/* Default body styles */
body {
@apply bg-gray-50 text-gray-900 antialiased;
}
/* Dark mode body */
.dark body {
@apply bg-gray-900 text-gray-200;
}
/* Focus visible for accessibility */
:focus-visible {
@apply outline-2 outline-offset-2 outline-purple-500;
}
.dark :focus-visible {
@apply outline-purple-400;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,233 +0,0 @@
const defaultTheme = require('tailwindcss/defaultTheme')
const plugin = require('tailwindcss/plugin')
const Color = require('color')
module.exports = {
purge: {
content: [
'public/**/*.html',
'app/templates/**/*.html',
'static/**/*.js',
],
// Safelist classes used dynamically in Alpine.js expressions
safelist: [
'bg-orange-600',
'bg-green-600',
'bg-red-600',
'hover:bg-orange-700',
'hover:bg-green-700',
'hover:bg-red-700',
],
},
theme: {
themeVariants: ['dark'],
customForms: (theme) => ({
default: {
'input, textarea': {
'&::placeholder': {
color: theme('colors.gray.400'),
},
},
},
}),
colors: {
transparent: 'transparent',
white: '#ffffff',
black: '#000000',
gray: {
'50': '#f9fafb',
'100': '#f4f5f7',
'200': '#e5e7eb',
'300': '#d5d6d7',
'400': '#9e9e9e',
'500': '#707275',
'600': '#4c4f52',
'700': '#24262d',
'800': '#1a1c23',
'900': '#121317',
// default values from Tailwind UI palette
// '300': '#d2d6dc',
// '400': '#9fa6b2',
// '500': '#6b7280',
// '600': '#4b5563',
// '700': '#374151',
// '800': '#252f3f',
// '900': '#161e2e',
},
'cool-gray': {
'50': '#fbfdfe',
'100': '#f1f5f9',
'200': '#e2e8f0',
'300': '#cfd8e3',
'400': '#97a6ba',
'500': '#64748b',
'600': '#475569',
'700': '#364152',
'800': '#27303f',
'900': '#1a202e',
},
red: {
'50': '#fdf2f2',
'100': '#fde8e8',
'200': '#fbd5d5',
'300': '#f8b4b4',
'400': '#f98080',
'500': '#f05252',
'600': '#e02424',
'700': '#c81e1e',
'800': '#9b1c1c',
'900': '#771d1d',
},
orange: {
'50': '#fff8f1',
'100': '#feecdc',
'200': '#fcd9bd',
'300': '#fdba8c',
'400': '#ff8a4c',
'500': '#ff5a1f',
'600': '#d03801',
'700': '#b43403',
'800': '#8a2c0d',
'900': '#771d1d',
},
yellow: {
'50': '#fdfdea',
'100': '#fdf6b2',
'200': '#fce96a',
'300': '#faca15',
'400': '#e3a008',
'500': '#c27803',
'600': '#9f580a',
'700': '#8e4b10',
'800': '#723b13',
'900': '#633112',
},
green: {
'50': '#f3faf7',
'100': '#def7ec',
'200': '#bcf0da',
'300': '#84e1bc',
'400': '#31c48d',
'500': '#0e9f6e',
'600': '#057a55',
'700': '#046c4e',
'800': '#03543f',
'900': '#014737',
},
teal: {
'50': '#edfafa',
'100': '#d5f5f6',
'200': '#afecef',
'300': '#7edce2',
'400': '#16bdca',
'500': '#0694a2',
'600': '#047481',
'700': '#036672',
'800': '#05505c',
'900': '#014451',
},
blue: {
'50': '#ebf5ff',
'100': '#e1effe',
'200': '#c3ddfd',
'300': '#a4cafe',
'400': '#76a9fa',
'500': '#3f83f8',
'600': '#1c64f2',
'700': '#1a56db',
'800': '#1e429f',
'900': '#233876',
},
indigo: {
'50': '#f0f5ff',
'100': '#e5edff',
'200': '#cddbfe',
'300': '#b4c6fc',
'400': '#8da2fb',
'500': '#6875f5',
'600': '#5850ec',
'700': '#5145cd',
'800': '#42389d',
'900': '#362f78',
},
purple: {
'50': '#f6f5ff',
'100': '#edebfe',
'200': '#dcd7fe',
'300': '#cabffd',
'400': '#ac94fa',
'500': '#9061f9',
'600': '#7e3af2',
'700': '#6c2bd9',
'800': '#5521b5',
'900': '#4a1d96',
},
pink: {
'50': '#fdf2f8',
'100': '#fce8f3',
'200': '#fad1e8',
'300': '#f8b4d9',
'400': '#f17eb8',
'500': '#e74694',
'600': '#d61f69',
'700': '#bf125d',
'800': '#99154b',
'900': '#751a3d',
},
},
extend: {
maxHeight: {
'0': '0',
xl: '36rem',
},
fontFamily: {
sans: ['Inter', ...defaultTheme.fontFamily.sans],
},
},
},
variants: {
backgroundColor: [
'hover',
'focus',
'active',
'odd',
'dark',
'dark:hover',
'dark:focus',
'dark:active',
'dark:odd',
],
display: ['responsive', 'dark'],
textColor: [
'focus-within',
'hover',
'active',
'dark',
'dark:focus-within',
'dark:hover',
'dark:active',
],
placeholderColor: ['focus', 'dark', 'dark:focus'],
borderColor: ['focus', 'hover', 'dark', 'dark:focus', 'dark:hover'],
divideColor: ['dark'],
boxShadow: ['focus', 'dark:focus'],
},
plugins: [
require('tailwindcss-multi-theme'),
require('@tailwindcss/custom-forms'),
plugin(({ addUtilities, e, theme, variants }) => {
const newUtilities = {}
Object.entries(theme('colors')).map(([name, value]) => {
if (name === 'transparent' || name === 'current') return
const color = value[300] ? value[300] : value
const hsla = Color(color).alpha(0.45).hsl().string()
newUtilities[`.shadow-outline-${name}`] = {
'box-shadow': `0 0 0 3px ${hsla}`,
}
})
addUtilities(newUtilities, variants('boxShadow'))
}),
],
}