diff --git a/app/api/deps.py b/app/api/deps.py index 8a254531..88d19721 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -674,3 +674,157 @@ def get_user_permissions( return [] +# ============================================================================ +# OPTIONAL AUTHENTICATION (For Login Page Redirects) +# ============================================================================ + +def get_current_admin_optional( + request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + admin_token: Optional[str] = Cookie(None), + db: Session = Depends(get_db), +) -> Optional[User]: + """ + Get current admin user from admin_token cookie or Authorization header. + + Returns None instead of raising exceptions if not authenticated. + Used for login pages to check if user is already authenticated. + + Priority: + 1. Authorization header (API calls) + 2. admin_token cookie (page navigation) + + Args: + request: FastAPI request + credentials: Optional Bearer token from header + admin_token: Optional token from admin_token cookie + db: Database session + + Returns: + User: Authenticated admin user if valid token exists + None: If no token, invalid token, or user is not admin + """ + token, source = _get_token_from_request( + credentials, + admin_token, + "admin_token", + str(request.url.path) + ) + + if not token: + return None + + try: + # Validate token and get user + user = _validate_user_token(token, db) + + # Verify user is admin + if user.role == "admin": + return user + except Exception: + # Invalid token or other error + pass + + return None + + +def get_current_vendor_optional( + request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + vendor_token: Optional[str] = Cookie(None), + db: Session = Depends(get_db), +) -> Optional[User]: + """ + Get current vendor user from vendor_token cookie or Authorization header. + + Returns None instead of raising exceptions if not authenticated. + Used for login pages to check if user is already authenticated. + + Priority: + 1. Authorization header (API calls) + 2. vendor_token cookie (page navigation) + + Args: + request: FastAPI request + credentials: Optional Bearer token from header + vendor_token: Optional token from vendor_token cookie + db: Database session + + Returns: + User: Authenticated vendor user if valid token exists + None: If no token, invalid token, or user is not vendor + """ + token, source = _get_token_from_request( + credentials, + vendor_token, + "vendor_token", + str(request.url.path) + ) + + if not token: + return None + + try: + # Validate token and get user + user = _validate_user_token(token, db) + + # Verify user is vendor + if user.role == "vendor": + return user + except Exception: + # Invalid token or other error + pass + + return None + + +def get_current_customer_optional( + request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + customer_token: Optional[str] = Cookie(None), + db: Session = Depends(get_db), +) -> Optional[User]: + """ + Get current customer user from customer_token cookie or Authorization header. + + Returns None instead of raising exceptions if not authenticated. + Used for login pages to check if user is already authenticated. + + Priority: + 1. Authorization header (API calls) + 2. customer_token cookie (page navigation) + + Args: + request: FastAPI request + credentials: Optional Bearer token from header + customer_token: Optional token from customer_token cookie + db: Database session + + Returns: + User: Authenticated customer user if valid token exists + None: If no token, invalid token, or user is not customer + """ + token, source = _get_token_from_request( + credentials, + customer_token, + "customer_token", + str(request.url.path) + ) + + if not token: + return None + + try: + # Validate token and get user + user = _validate_user_token(token, db) + + # Verify user is customer + if user.role == "customer": + return user + except Exception: + # Invalid token or other error + pass + + return None + + diff --git a/app/routes/admin_pages.py b/app/routes/admin_pages.py index ef36c4fc..0a0034b7 100644 --- a/app/routes/admin_pages.py +++ b/app/routes/admin_pages.py @@ -27,8 +27,13 @@ from fastapi import APIRouter, Request, Depends, Path from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from sqlalchemy.orm import Session +from typing import Optional -from app.api.deps import get_current_admin_from_cookie_or_header, get_db +from app.api.deps import ( + get_current_admin_from_cookie_or_header, + get_current_admin_optional, + get_db +) from models.database.user import User router = APIRouter() @@ -40,23 +45,37 @@ templates = Jinja2Templates(directory="app/templates") # ============================================================================ @router.get("/", response_class=RedirectResponse, include_in_schema=False) -async def admin_root(): +async def admin_root( + current_user: Optional[User] = Depends(get_current_admin_optional) +): """ - Redirect /admin/ to /admin/login. + Redirect /admin/ based on authentication status. - Simple approach: - - Unauthenticated users → see login form - - Authenticated users → login page shows form (they can navigate to dashboard) + - Authenticated admin users → /admin/dashboard + - Unauthenticated users → /admin/login """ + if current_user: + # User is already logged in as admin, redirect to dashboard + return RedirectResponse(url="/admin/dashboard", status_code=302) + return RedirectResponse(url="/admin/login", status_code=302) @router.get("/login", response_class=HTMLResponse, include_in_schema=False) -async def admin_login_page(request: Request): +async def admin_login_page( + request: Request, + current_user: Optional[User] = Depends(get_current_admin_optional) +): """ Render admin login page. - No authentication required. + + If user is already authenticated as admin, redirect to dashboard. + Otherwise, show login form. """ + if current_user: + # User is already logged in as admin, redirect to dashboard + return RedirectResponse(url="/admin/dashboard", status_code=302) + return templates.TemplateResponse( "admin/login.html", {"request": request} diff --git a/app/routes/vendor_pages.py b/app/routes/vendor_pages.py index 839c0cf3..779efab4 100644 --- a/app/routes/vendor_pages.py +++ b/app/routes/vendor_pages.py @@ -24,8 +24,12 @@ Routes: from fastapi import APIRouter, Request, Depends, Path from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates +from typing import Optional -from app.api.deps import get_current_vendor_from_cookie_or_header +from app.api.deps import ( + get_current_vendor_from_cookie_or_header, + get_current_vendor_optional +) from models.database.user import User router = APIRouter() @@ -46,28 +50,44 @@ async def vendor_root_no_slash(vendor_code: str = Path(..., description="Vendor @router.get("/{vendor_code}/", response_class=RedirectResponse, include_in_schema=False) -async def vendor_root(vendor_code: str = Path(..., description="Vendor code")): +async def vendor_root( + vendor_code: str = Path(..., description="Vendor code"), + current_user: Optional[User] = Depends(get_current_vendor_optional) +): """ - Redirect /vendor/{code}/ to login page. - Simple approach - let login page handle authenticated redirects. + Redirect /vendor/{code}/ based on authentication status. + + - Authenticated vendor users → /vendor/{code}/dashboard + - Unauthenticated users → /vendor/{code}/login """ + if current_user: + # User is already logged in as vendor, redirect to dashboard + return RedirectResponse(url=f"/vendor/{vendor_code}/dashboard", status_code=302) + return RedirectResponse(url=f"/vendor/{vendor_code}/login", status_code=302) @router.get("/{vendor_code}/login", response_class=HTMLResponse, include_in_schema=False) async def vendor_login_page( request: Request, - vendor_code: str = Path(..., description="Vendor code") + vendor_code: str = Path(..., description="Vendor code"), + current_user: Optional[User] = Depends(get_current_vendor_optional) ): """ Render vendor login page. - No authentication required. + + If user is already authenticated as vendor, redirect to dashboard. + Otherwise, show login form. JavaScript will: - Load vendor info via API - Handle login form submission - Redirect to dashboard on success """ + if current_user: + # User is already logged in as vendor, redirect to dashboard + return RedirectResponse(url=f"/vendor/{vendor_code}/dashboard", status_code=302) + return templates.TemplateResponse( "vendor/login.html", { @@ -97,7 +117,7 @@ async def vendor_dashboard_page( - Handle all interactivity """ return templates.TemplateResponse( - "vendor/admin/dashboard.html", + "vendor/dashboard.html", { "request": request, "user": current_user, @@ -121,7 +141,7 @@ async def vendor_products_page( JavaScript loads product list via API. """ return templates.TemplateResponse( - "vendor/admin/products.html", + "vendor/products.html", { "request": request, "user": current_user, @@ -145,7 +165,7 @@ async def vendor_orders_page( JavaScript loads order list via API. """ return templates.TemplateResponse( - "vendor/admin/orders.html", + "vendor/orders.html", { "request": request, "user": current_user, @@ -169,7 +189,7 @@ async def vendor_customers_page( JavaScript loads customer list via API. """ return templates.TemplateResponse( - "vendor/admin/customers.html", + "vendor/customers.html", { "request": request, "user": current_user, @@ -193,7 +213,7 @@ async def vendor_inventory_page( JavaScript loads inventory data via API. """ return templates.TemplateResponse( - "vendor/admin/inventory.html", + "vendor/inventory.html", { "request": request, "user": current_user, @@ -217,7 +237,7 @@ async def vendor_marketplace_page( JavaScript loads import jobs and products via API. """ return templates.TemplateResponse( - "vendor/admin/marketplace.html", + "vendor/marketplace.html", { "request": request, "user": current_user, @@ -241,7 +261,7 @@ async def vendor_team_page( JavaScript loads team members via API. """ return templates.TemplateResponse( - "vendor/admin/team.html", + "vendor/team.html", { "request": request, "user": current_user, @@ -251,9 +271,29 @@ async def vendor_team_page( # ============================================================================ -# SETTINGS +# PROFILE & SETTINGS # ============================================================================ +@router.get("/{vendor_code}/profile", response_class=HTMLResponse, include_in_schema=False) +async def vendor_profile_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header) +): + """ + Render vendor profile page. + User can manage their personal profile information. + """ + return templates.TemplateResponse( + "vendor/profile.html", + { + "request": request, + "user": current_user, + "vendor_code": vendor_code, + } + ) + + @router.get("/{vendor_code}/settings", response_class=HTMLResponse, include_in_schema=False) async def vendor_settings_page( request: Request, diff --git a/docs/api/authentication.md b/docs/api/authentication.md index ac852e61..c83b0f83 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -506,6 +506,172 @@ current_customer: Customer = Depends(get_current_customer_api) current_user: User = Depends(get_current_user) ``` +--- + +### Optional Authentication Dependencies + +These dependencies return `None` instead of raising exceptions when authentication fails. Used for login pages and public routes that need to conditionally check if a user is authenticated. + +#### `get_current_admin_optional()` + +**Purpose:** Check if admin user is authenticated (without enforcing) +**Accepts:** Cookie (`admin_token`) OR Authorization header +**Returns:** +- `User` object with `role="admin"` if authenticated +- `None` if no token, invalid token, or user is not admin +**Raises:** Never raises exceptions + +**Usage:** +```python +# Login page redirect +@router.get("/admin/login") +async def admin_login_page( + request: Request, + current_user: Optional[User] = Depends(get_current_admin_optional) +): + if current_user: + # User already logged in, redirect to dashboard + return RedirectResponse(url="/admin/dashboard", status_code=302) + + # Not logged in, show login form + return templates.TemplateResponse("admin/login.html", {"request": request}) +``` + +**Use Cases:** +- Login pages (redirect if already authenticated) +- Public pages with conditional admin content +- Root redirects based on authentication status + +#### `get_current_vendor_optional()` + +**Purpose:** Check if vendor user is authenticated (without enforcing) +**Accepts:** Cookie (`vendor_token`) OR Authorization header +**Returns:** +- `User` object with `role="vendor"` if authenticated +- `None` if no token, invalid token, or user is not vendor +**Raises:** Never raises exceptions + +**Usage:** +```python +# Login page redirect +@router.get("/vendor/{vendor_code}/login") +async def vendor_login_page( + request: Request, + vendor_code: str = Path(...), + current_user: Optional[User] = Depends(get_current_vendor_optional) +): + if current_user: + # User already logged in, redirect to dashboard + return RedirectResponse(url=f"/vendor/{vendor_code}/dashboard", status_code=302) + + # Not logged in, show login form + return templates.TemplateResponse("vendor/login.html", { + "request": request, + "vendor_code": vendor_code + }) +``` + +**Use Cases:** +- Login pages (redirect if already authenticated) +- Public vendor pages with conditional content +- Root redirects based on authentication status + +#### `get_current_customer_optional()` + +**Purpose:** Check if customer user is authenticated (without enforcing) +**Accepts:** Cookie (`customer_token`) OR Authorization header +**Returns:** +- `User` object with `role="customer"` if authenticated +- `None` if no token, invalid token, or user is not customer +**Raises:** Never raises exceptions + +**Usage:** +```python +# Shop login page redirect +@router.get("/shop/account/login") +async def customer_login_page( + request: Request, + current_user: Optional[User] = Depends(get_current_customer_optional) +): + if current_user: + # User already logged in, redirect to account page + return RedirectResponse(url="/shop/account", status_code=302) + + # Not logged in, show login form + return templates.TemplateResponse("shop/login.html", {"request": request}) +``` + +**Use Cases:** +- Login pages (redirect if already authenticated) +- Public shop pages with conditional customer content (e.g., "My Orders" link) +- Root redirects based on authentication status + +--- + +### Required vs Optional Dependencies + +Understanding when to use each type: + +#### Admin Context + +| Scenario | Use | Returns | On Auth Failure | +|----------|-----|---------|-----------------| +| **Protected page (dashboard, settings)** | `get_current_admin_from_cookie_or_header` | `User` | Raises 401 exception | +| **Login page** | `get_current_admin_optional` | `Optional[User]` | Returns `None` | +| **API endpoint** | `get_current_admin_api` | `User` | Raises 401 exception | +| **Public page with conditional content** | `get_current_admin_optional` | `Optional[User]` | Returns `None` | + +#### Vendor Context + +| Scenario | Use | Returns | On Auth Failure | +|----------|-----|---------|-----------------| +| **Protected page (dashboard, products)** | `get_current_vendor_from_cookie_or_header` | `User` | Raises 401 exception | +| **Login page** | `get_current_vendor_optional` | `Optional[User]` | Returns `None` | +| **API endpoint** | `get_current_vendor_api` | `User` | Raises 401 exception | +| **Public page with conditional content** | `get_current_vendor_optional` | `Optional[User]` | Returns `None` | + +#### Customer Context + +| Scenario | Use | Returns | On Auth Failure | +|----------|-----|---------|-----------------| +| **Protected page (account, orders)** | `get_current_customer_from_cookie_or_header` | `User` | Raises 401 exception | +| **Login page** | `get_current_customer_optional` | `Optional[User]` | Returns `None` | +| **API endpoint** | `get_current_customer_api` | `User` | Raises 401 exception | +| **Public page with conditional content** | `get_current_customer_optional` | `Optional[User]` | Returns `None` | + +**Example Flow:** + +```python +# ❌ WRONG: Using required auth on login page +@router.get("/admin/login") +async def admin_login_page( + current_user: User = Depends(get_current_admin_from_cookie_or_header) +): + # This will return 401 error if not logged in! + # Login page will never render for unauthenticated users + ... + +# ✅ CORRECT: Using optional auth on login page +@router.get("/admin/login") +async def admin_login_page( + current_user: Optional[User] = Depends(get_current_admin_optional) +): + # Returns None if not logged in, page renders normally + if current_user: + return RedirectResponse(url="/admin/dashboard") + return templates.TemplateResponse("login.html", ...) + + +# ✅ CORRECT: Using required auth on protected page +@router.get("/admin/dashboard") +async def admin_dashboard( + current_user: User = Depends(get_current_admin_from_cookie_or_header) +): + # Automatically returns 401 if not authenticated + # Only runs if user is authenticated admin + ... +``` + ### Login Response Format All login endpoints return: diff --git a/docs/architecture/url-routing/overview.md b/docs/architecture/url-routing/overview.md index a2d96b62..7b3dd5fb 100644 --- a/docs/architecture/url-routing/overview.md +++ b/docs/architecture/url-routing/overview.md @@ -6,7 +6,7 @@ There are three ways depending on the deployment mode: -**⚠️ Important:** This guide describes **customer-facing shop routes**. For vendor dashboard/management routes, see [Vendor Dashboard Documentation](../../vendor/). The shop uses `/vendors/{code}/shop/*` (plural) in path-based mode, while the vendor dashboard uses `/vendor/{code}/*` (singular). +**⚠️ Important:** This guide describes **customer-facing shop routes**. For vendor dashboard/management routes, see [Vendor Frontend Architecture](../../frontend/vendor/architecture.md). The shop uses `/vendors/{code}/shop/*` (plural) in path-based mode, while the vendor dashboard uses `/vendor/{code}/*` (singular). ### 1. **SUBDOMAIN MODE** (Production - Recommended) ```