Some checks failed
Align Alpine.js base component naming with storefront terminology. Updated across all storefront JS, templates, and documentation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
651 lines
20 KiB
Markdown
651 lines
20 KiB
Markdown
# Customer Authentication Implementation
|
|
|
|
**Date**: 2025-11-24
|
|
**Status**: Completed
|
|
|
|
## Overview
|
|
|
|
This document describes the implementation of customer authentication for the shop frontend, including login, registration, and account management pages. This work creates a complete separation between customer authentication and admin/store authentication systems.
|
|
|
|
## Problem Statement
|
|
|
|
The shop frontend needed proper authentication pages (login, registration, forgot password) and a working customer authentication system. The initial implementation had several issues:
|
|
|
|
1. No styled authentication pages for customers
|
|
2. Customer authentication was incorrectly trying to use the User model (admins/stores)
|
|
3. Cookie paths were hardcoded and didn't work with multi-access routing (domain, subdomain, path-based)
|
|
4. Store detection method was inconsistent between direct path access and API calls via referer
|
|
|
|
## Solution Architecture
|
|
|
|
### 1. Customer vs User Separation
|
|
|
|
**Key Insight**: Customers are NOT users. They are a separate entity in the system.
|
|
|
|
- **Users** (`models/database/user.py`): Admin and store accounts
|
|
- Have `role` field (admin/store)
|
|
- Have `username` field
|
|
- Managed via `app/services/auth_service.py`
|
|
|
|
- **Customers** (`models/database/customer.py`): Shop customers
|
|
- Store-scoped (each store has independent customers)
|
|
- No `role` or `username` fields
|
|
- Have `customer_number`, `total_orders`, store relationship
|
|
- Managed via `app/services/customer_service.py`
|
|
|
|
### 2. JWT Token Structure
|
|
|
|
Customer tokens have a distinct structure:
|
|
|
|
```python
|
|
{
|
|
"sub": str(customer.id), # Customer ID
|
|
"email": customer.email,
|
|
"store_id": store_id, # Important: Store isolation
|
|
"type": "customer", # CRITICAL: Distinguishes from User tokens
|
|
"exp": expire_timestamp,
|
|
"iat": issued_at_timestamp,
|
|
}
|
|
```
|
|
|
|
User tokens have `type` implicitly set to user role (admin/store) and different payload structure.
|
|
|
|
### 3. Cookie Path Management
|
|
|
|
Cookies must be set with paths that match how the store is accessed:
|
|
|
|
| Access Method | Example URL | Cookie Path |
|
|
|--------------|-------------|-------------|
|
|
| Domain | `orion.lu/shop/account/login` | `/shop` |
|
|
| Subdomain | `orion.localhost/shop/account/login` | `/shop` |
|
|
| Path-based | `localhost/stores/orion/shop/account/login` | `/stores/orion/shop` |
|
|
|
|
This ensures cookies are only sent to the correct store's routes.
|
|
|
|
## Implementation Details
|
|
|
|
### Files Created
|
|
|
|
1. **`app/templates/shop/account/login.html`**
|
|
- Customer login page
|
|
- Extends `shop/base.html` (follows design pattern)
|
|
- Uses Tailwind CSS and Alpine.js
|
|
- Theme-aware styling with CSS variables
|
|
- Two-column layout (branding + form)
|
|
- Form validation and error handling
|
|
|
|
2. **`app/templates/shop/account/register.html`**
|
|
- Customer registration page
|
|
- Fields: first_name, last_name, email, phone (optional), password
|
|
- Client-side validation
|
|
- Marketing consent checkbox
|
|
- Theme integration
|
|
|
|
3. **`app/templates/shop/account/forgot-password.html`**
|
|
- Password reset request page
|
|
- Two-state UI (form → success)
|
|
- Email validation
|
|
|
|
4. **`app/templates/shop/account/dashboard.html`**
|
|
- Customer account dashboard
|
|
- Displays account summary, order statistics
|
|
- Quick links to orders, profile, addresses
|
|
- Logout functionality
|
|
- Follows shop design pattern (extends base.html)
|
|
|
|
### Important Schema Change
|
|
|
|
#### `models/schema/auth.py` - Unified Login Schema
|
|
|
|
Changed the `UserLogin` schema to use `email_or_username` instead of `username` to support both username and email login across all contexts (admin, store, and customer).
|
|
|
|
**Before**:
|
|
```python
|
|
class UserLogin(BaseModel):
|
|
username: str
|
|
password: str
|
|
```
|
|
|
|
**After**:
|
|
```python
|
|
class UserLogin(BaseModel):
|
|
email_or_username: str = Field(..., description="Username or email address")
|
|
password: str
|
|
store_code: Optional[str] = Field(None, description="Optional store code for context")
|
|
```
|
|
|
|
**Impact**: This change affects all login endpoints:
|
|
- Admin login: `/api/v1/admin/auth/login`
|
|
- Store login: `/api/v1/store/auth/login`
|
|
- Customer login: `/api/v1/shop/auth/login`
|
|
|
|
**Updated Files**:
|
|
- `app/services/auth_service.py` - Changed `user_credentials.username` to `user_credentials.email_or_username`
|
|
- `app/api/v1/admin/auth.py` - Updated logging to use `email_or_username`
|
|
- `static/admin/js/login.js` - Send `email_or_username` in payload
|
|
- `static/store/js/login.js` - Send `email_or_username` in payload
|
|
|
|
### Files Modified
|
|
|
|
#### 1. `app/api/v1/shop/auth.py`
|
|
|
|
**Changes**:
|
|
- Added `CustomerLoginResponse` model (uses `CustomerResponse` instead of `UserResponse`)
|
|
- Updated `customer_login` endpoint to:
|
|
- Calculate cookie path dynamically based on store access method
|
|
- Set cookie with correct path for multi-access support
|
|
- Return `CustomerLoginResponse` with proper customer data
|
|
- Updated `customer_logout` endpoint to calculate cookie path dynamically
|
|
|
|
**Key Code**:
|
|
```python
|
|
# Calculate cookie path based on store access method
|
|
store_context = getattr(request.state, 'store_context', None)
|
|
access_method = store_context.get('detection_method', 'unknown') if store_context else 'unknown'
|
|
|
|
cookie_path = "/shop" # Default for domain/subdomain access
|
|
if access_method == "path":
|
|
# For path-based access like /stores/orion/shop
|
|
full_prefix = store_context.get('full_prefix', '/store/') if store_context else '/store/'
|
|
cookie_path = f"{full_prefix}{store.subdomain}/shop"
|
|
|
|
response.set_cookie(
|
|
key="customer_token",
|
|
value=token,
|
|
httponly=True,
|
|
secure=should_use_secure_cookies(),
|
|
samesite="lax",
|
|
max_age=expires_in,
|
|
path=cookie_path, # Dynamic path
|
|
)
|
|
```
|
|
|
|
#### 2. `app/services/customer_service.py`
|
|
|
|
**Changes**:
|
|
- Updated `login_customer` to create JWT tokens directly using `auth_manager`
|
|
- No longer tries to use `auth_service.create_access_token()` (that's for Users only)
|
|
- Directly uses `jose.jwt.encode()` with custom customer payload
|
|
|
|
**Key Code**:
|
|
```python
|
|
from jose import jwt
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
auth_manager = self.auth_service.auth_manager
|
|
expires_delta = timedelta(minutes=auth_manager.token_expire_minutes)
|
|
expire = datetime.now(timezone.utc) + expires_delta
|
|
|
|
payload = {
|
|
"sub": str(customer.id),
|
|
"email": customer.email,
|
|
"store_id": store_id,
|
|
"type": "customer", # Critical distinction
|
|
"exp": expire,
|
|
"iat": datetime.now(timezone.utc),
|
|
}
|
|
|
|
token = jwt.encode(payload, auth_manager.secret_key, algorithm=auth_manager.algorithm)
|
|
```
|
|
|
|
#### 3. `app/api/deps.py`
|
|
|
|
**Major Rewrite**: `get_current_customer_from_cookie_or_header`
|
|
|
|
**Before**: Tried to validate customer tokens as User tokens, expected `role` field
|
|
|
|
**After**:
|
|
- Decodes JWT manually
|
|
- Validates `type == "customer"` in payload
|
|
- Loads Customer from database (not User)
|
|
- Returns Customer object
|
|
|
|
**Key Code**:
|
|
```python
|
|
def get_current_customer_from_cookie_or_header(
|
|
request: Request,
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
|
customer_token: Optional[str] = Cookie(None),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
from models.database.customer import Customer
|
|
from jose import jwt, JWTError
|
|
|
|
token, source = _get_token_from_request(...)
|
|
|
|
if not token:
|
|
raise InvalidTokenException("Customer authentication required")
|
|
|
|
# Decode and validate customer JWT token
|
|
payload = jwt.decode(token, auth_manager.secret_key, algorithms=[auth_manager.algorithm])
|
|
|
|
# Verify this is a customer token
|
|
if payload.get("type") != "customer":
|
|
raise InvalidTokenException("Customer authentication required")
|
|
|
|
customer_id = payload.get("sub")
|
|
customer = db.query(Customer).filter(Customer.id == int(customer_id)).first()
|
|
|
|
if not customer or not customer.is_active:
|
|
raise InvalidTokenException("Customer not found or inactive")
|
|
|
|
return customer # Returns Customer, not User
|
|
```
|
|
|
|
#### 4. `app/routes/shop_pages.py`
|
|
|
|
**Changes**:
|
|
- Changed import from `User` to `Customer`
|
|
- Updated all protected route handlers:
|
|
- Changed parameter type from `current_user: User` to `current_customer: Customer`
|
|
- Updated function calls from `user=current_user` to `user=current_customer`
|
|
|
|
**Affected Routes**:
|
|
- `/account/dashboard`
|
|
- `/account/orders`
|
|
- `/account/orders/{order_id}`
|
|
- `/account/profile`
|
|
- `/account/addresses`
|
|
- `/account/wishlist`
|
|
- `/account/reviews`
|
|
|
|
#### 5. `middleware/store_context.py`
|
|
|
|
**Critical Fix**: Harmonized store detection methods
|
|
|
|
**Problem**:
|
|
- Direct page access: `detection_method = "path"`
|
|
- API call via referer: `detection_method = "referer_path"`
|
|
- This inconsistency broke cookie path calculation
|
|
|
|
**Solution**:
|
|
When detecting store from referer path, use the same `detection_method = "path"` and include the same fields (`full_prefix`, `path_prefix`) as direct path detection.
|
|
|
|
**Key Code**:
|
|
```python
|
|
# Method 1: Path-based detection from referer path
|
|
if referer_path.startswith("/stores/") or referer_path.startswith("/store/"):
|
|
prefix = "/stores/" if referer_path.startswith("/stores/") else "/store/"
|
|
path_parts = referer_path[len(prefix):].split("/")
|
|
if len(path_parts) >= 1 and path_parts[0]:
|
|
store_code = path_parts[0]
|
|
prefix_len = len(prefix)
|
|
|
|
# Use "path" as detection_method to be consistent with direct path detection
|
|
return {
|
|
"subdomain": store_code,
|
|
"detection_method": "path", # Consistent!
|
|
"path_prefix": referer_path[:prefix_len + len(store_code)],
|
|
"full_prefix": prefix,
|
|
"host": referer_host,
|
|
"referer": referer,
|
|
}
|
|
```
|
|
|
|
#### 6. `app/exceptions/handler.py`
|
|
|
|
**Changes**:
|
|
- Password sanitization in validation error logging
|
|
- Already had proper redirect logic for customer login (no changes needed)
|
|
|
|
#### 7. `models/schema/auth.py`
|
|
|
|
**Changes**:
|
|
- Updated `UserLogin` schema to accept `email_or_username` instead of `username`
|
|
- This allows customers to login with email (they don't have usernames)
|
|
|
|
### Multi-Access Routing Support
|
|
|
|
The implementation properly supports all three store access methods:
|
|
|
|
#### Domain-based Access
|
|
```
|
|
URL: https://orion.lu/shop/account/login
|
|
Cookie Path: /shop
|
|
Cookie Sent To: https://orion.lu/shop/*
|
|
```
|
|
|
|
#### Subdomain-based Access
|
|
```
|
|
URL: https://orion.myplatform.com/shop/account/login
|
|
Cookie Path: /shop
|
|
Cookie Sent To: https://orion.myplatform.com/shop/*
|
|
```
|
|
|
|
#### Path-based Access
|
|
```
|
|
URL: https://myplatform.com/stores/orion/shop/account/login
|
|
Cookie Path: /stores/orion/shop
|
|
Cookie Sent To: https://myplatform.com/stores/orion/shop/*
|
|
```
|
|
|
|
## Authentication Flow
|
|
|
|
### Login Flow
|
|
|
|
1. **User loads login page** → `GET /stores/orion/shop/account/login`
|
|
- Middleware detects store from path
|
|
- Sets `detection_method = "path"` in store_context
|
|
- Renders login template
|
|
|
|
2. **User submits credentials** → `POST /api/v1/shop/auth/login`
|
|
- Middleware detects store from Referer header
|
|
- Sets `detection_method = "path"` (harmonized!)
|
|
- Validates credentials via `customer_service.login_customer()`
|
|
- Creates JWT token with `type: "customer"`
|
|
- Calculates cookie path based on access method
|
|
- Sets `customer_token` cookie with correct path
|
|
- Returns token + customer data
|
|
|
|
3. **Browser redirects to dashboard** → `GET /stores/orion/shop/account/dashboard`
|
|
- Browser sends `customer_token` cookie (path matches!)
|
|
- Dependency `get_current_customer_from_cookie_or_header` extracts token
|
|
- Decodes JWT, validates `type == "customer"`
|
|
- Loads Customer from database
|
|
- Renders dashboard with customer data
|
|
|
|
### Logout Flow
|
|
|
|
1. **User clicks logout button** → Shows Tailwind modal confirmation
|
|
- Custom modal (not browser confirm dialog)
|
|
- Alpine.js state management
|
|
- Smooth animations with transitions
|
|
- Dark mode support
|
|
|
|
2. **User confirms logout** → `POST /api/v1/shop/auth/logout`
|
|
- Calculates cookie path (same logic as login)
|
|
- Deletes cookie with matching path
|
|
- Returns success message
|
|
|
|
3. **Frontend redirects to login page**
|
|
- Shows success toast notification
|
|
- Clears localStorage token
|
|
- Redirects after 500ms delay
|
|
|
|
## Security Features
|
|
|
|
### Cookie Security
|
|
|
|
```python
|
|
response.set_cookie(
|
|
key="customer_token",
|
|
value=token,
|
|
httponly=True, # JavaScript cannot access (XSS protection)
|
|
secure=True, # HTTPS only (production/staging)
|
|
samesite="lax", # CSRF protection
|
|
max_age=1800, # 30 minutes (matches JWT expiry)
|
|
path=cookie_path, # Restricted to store's shop routes
|
|
)
|
|
```
|
|
|
|
### Token Validation
|
|
|
|
- JWT expiration checked
|
|
- Customer active status verified
|
|
- Token type validated (`type == "customer"`)
|
|
- Store isolation enforced (customer must belong to store)
|
|
|
|
### Password Security
|
|
|
|
- Bcrypt hashing via `auth_manager.hash_password()`
|
|
- Validation errors sanitized (passwords never logged)
|
|
- Minimum password length enforced
|
|
|
|
## Design Patterns Followed
|
|
|
|
### Frontend Templates
|
|
|
|
All authentication pages follow the shop template pattern:
|
|
|
|
```jinja2
|
|
{% extends "shop/base.html" %}
|
|
|
|
{% block title %}Page Title{% endblock %}
|
|
|
|
{% block alpine_data %}componentName(){% endblock %}
|
|
|
|
{% block content %}
|
|
<!-- Page content -->
|
|
{% endblock %}
|
|
|
|
{% block extra_scripts %}
|
|
<script>
|
|
function componentName() {
|
|
return {
|
|
...storefrontLayoutData(),
|
|
// Component-specific data/methods
|
|
}
|
|
}
|
|
</script>
|
|
{% endblock %}
|
|
```
|
|
|
|
**Benefits**:
|
|
- Consistent header/footer/navigation
|
|
- Theme CSS variables automatically injected
|
|
- Dark mode support
|
|
- Mobile responsive
|
|
- Alpine.js component pattern
|
|
|
|
### Service Layer
|
|
|
|
- Customer operations in `customer_service.py`
|
|
- Auth operations in `auth_service.py`
|
|
- Clear separation of concerns
|
|
- Database operations via SQLAlchemy ORM
|
|
|
|
### Exception Handling
|
|
|
|
- Custom exceptions for customer-specific errors
|
|
- Consistent error responses (JSON for API, HTML for pages)
|
|
- Automatic redirect to login on 401 for HTML page requests
|
|
|
|
### UI Components
|
|
|
|
#### Logout Confirmation Modal
|
|
|
|
Custom Tailwind CSS modal for logout confirmation instead of browser's native `confirm()` dialog.
|
|
|
|
**Features**:
|
|
- Beautiful animated modal with backdrop overlay
|
|
- Warning icon (red triangle with exclamation mark)
|
|
- Clear confirmation message
|
|
- Two action buttons: "Logout" (red) and "Cancel" (gray)
|
|
- Dark mode support
|
|
- Mobile responsive
|
|
- Keyboard accessible (ARIA attributes)
|
|
- Click backdrop to dismiss
|
|
|
|
**Implementation**:
|
|
```html
|
|
<!-- Modal trigger -->
|
|
<button @click="showLogoutModal = true">Logout</button>
|
|
|
|
<!-- Modal component -->
|
|
<div x-show="showLogoutModal" x-cloak class="fixed inset-0 z-50">
|
|
<!-- Backdrop with fade animation -->
|
|
<div x-show="showLogoutModal"
|
|
x-transition:enter="ease-out duration-300"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
@click="showLogoutModal = false"
|
|
class="fixed inset-0 bg-gray-500 bg-opacity-75">
|
|
</div>
|
|
|
|
<!-- Modal panel with slide+scale animation -->
|
|
<div x-show="showLogoutModal"
|
|
x-transition:enter="ease-out duration-300"
|
|
x-transition:enter-start="opacity-0 scale-95"
|
|
x-transition:enter-end="opacity-100 scale-100"
|
|
class="bg-white dark:bg-gray-800 rounded-lg">
|
|
<!-- Modal content -->
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
**Alpine.js Component**:
|
|
```javascript
|
|
function accountDashboard() {
|
|
return {
|
|
...storefrontLayoutData(),
|
|
showLogoutModal: false, // Modal state
|
|
|
|
confirmLogout() {
|
|
this.showLogoutModal = false;
|
|
// Perform logout API call
|
|
// Show toast notification
|
|
// Redirect to login
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Why Custom Modal vs Browser Confirm**:
|
|
- ✅ Consistent with design system
|
|
- ✅ Customizable styling and animations
|
|
- ✅ Dark mode support
|
|
- ✅ Better mobile experience
|
|
- ✅ More professional appearance
|
|
- ✅ Accessible (ARIA labels, keyboard navigation)
|
|
- ❌ Browser confirm: Cannot be styled, looks dated, poor mobile UX
|
|
|
|
## Testing Checklist
|
|
|
|
- [x] Customer can register new account
|
|
- [x] Customer can login with email/password
|
|
- [x] Admin can login with username (using unified schema)
|
|
- [x] Store can login with username (using unified schema)
|
|
- [x] Cookie is set with correct path for path-based access
|
|
- [x] Cookie is sent on subsequent requests to dashboard
|
|
- [x] Customer authentication dependency validates token correctly
|
|
- [x] Dashboard loads with customer data
|
|
- [x] Customer can logout
|
|
- [x] Logout confirmation modal displays correctly
|
|
- [x] Modal has smooth animations and transitions
|
|
- [x] Modal supports dark mode
|
|
- [x] Toast notification shows on logout
|
|
- [x] Cookie is properly deleted on logout
|
|
- [x] Unauthorized access redirects to login
|
|
- [x] Theme styling is applied correctly
|
|
- [x] Dark mode works
|
|
- [x] Mobile responsive layout
|
|
- [x] Admin/store login button spinner aligns correctly
|
|
|
|
## Known Limitations
|
|
|
|
1. **Password Reset Not Implemented**: `forgot-password` and `reset-password` endpoints are placeholders (TODO comments in code)
|
|
|
|
2. **Email Verification Not Implemented**: Customers are immediately active after registration
|
|
|
|
3. **Session Management**: No refresh tokens, single JWT with 30-minute expiry
|
|
|
|
4. **Account Pages Are Placeholders**:
|
|
- `/account/orders` - needs order history implementation
|
|
- `/account/profile` - needs profile editing implementation
|
|
- `/account/addresses` - needs address management implementation
|
|
|
|
## Future Enhancements
|
|
|
|
1. **Password Reset Flow**:
|
|
- Generate secure reset tokens
|
|
- Send password reset emails
|
|
- Token expiry and validation
|
|
- Password reset form
|
|
|
|
2. **Email Verification**:
|
|
- Send verification email on registration
|
|
- Verification token validation
|
|
- Resend verification email
|
|
|
|
3. **Account Management**:
|
|
- Edit profile (name, email, phone)
|
|
- Change password
|
|
- Manage addresses (CRUD)
|
|
- View order history with filtering/search
|
|
- Order tracking
|
|
|
|
4. **Security Enhancements**:
|
|
- Refresh tokens for longer sessions
|
|
- Rate limiting on login/registration
|
|
- Account lockout after failed attempts
|
|
- 2FA/MFA support
|
|
|
|
5. **User Experience**:
|
|
- Remember me functionality
|
|
- Social login (OAuth)
|
|
- Progressive disclosure of forms
|
|
- Better error messages
|
|
|
|
## References
|
|
|
|
- **Customer Model**: `models/database/customer.py`
|
|
- **Customer Service**: `app/services/customer_service.py`
|
|
- **Auth Endpoints**: `app/api/v1/shop/auth.py`
|
|
- **Auth Dependencies**: `app/api/deps.py`
|
|
- **Shop Routes**: `app/routes/shop_pages.py`
|
|
- **Store Context**: `middleware/store_context.py`
|
|
- **Templates**: `app/templates/shop/account/`
|
|
|
|
## Deployment Notes
|
|
|
|
### Environment Variables
|
|
|
|
No new environment variables required. Uses existing:
|
|
- `JWT_SECRET_KEY` - for token signing
|
|
- `JWT_EXPIRE_MINUTES` - token expiry (default: 30)
|
|
- `ENVIRONMENT` - for secure cookie setting
|
|
|
|
### Database
|
|
|
|
No migrations required. Uses existing `customer` table.
|
|
|
|
### Static Files
|
|
|
|
Ensure these files exist:
|
|
- `static/shared/js/log-config.js`
|
|
- `static/shared/js/icons.js`
|
|
- `static/shop/js/shop-layout.js`
|
|
- `static/shared/js/utils.js`
|
|
- `static/shared/js/api-client.js`
|
|
- `static/shop/css/shop.css`
|
|
|
|
## Troubleshooting
|
|
|
|
### Issue: "No token for path"
|
|
|
|
**Cause**: Cookie path doesn't match request path
|
|
|
|
**Solution**:
|
|
- Check store context middleware is running
|
|
- Verify `detection_method` is set correctly
|
|
- Confirm cookie path calculation includes store subdomain for path-based access
|
|
|
|
### Issue: "Invalid token type"
|
|
|
|
**Cause**: Trying to use User token for customer route or vice versa
|
|
|
|
**Solution**:
|
|
- Ensure customer login creates token with `type: "customer"`
|
|
- Verify dependency checks `type == "customer"`
|
|
|
|
### Issue: Cookie not sent by browser
|
|
|
|
**Cause**: Cookie path doesn't match or cookie expired
|
|
|
|
**Solution**:
|
|
- Check browser DevTools → Application → Cookies
|
|
- Verify cookie path matches request URL
|
|
- Check cookie expiry timestamp
|
|
|
|
## Summary
|
|
|
|
This implementation establishes a complete customer authentication system that is:
|
|
|
|
✅ **Secure**: HTTP-only cookies, CSRF protection, password hashing
|
|
✅ **Scalable**: Multi-tenant with store isolation
|
|
✅ **Flexible**: Supports domain, subdomain, and path-based access
|
|
✅ **Maintainable**: Clear separation of concerns, follows established patterns
|
|
✅ **User-Friendly**: Responsive design, theme integration, proper UX flows
|
|
|
|
The key architectural decision was recognizing that customers and users are fundamentally different entities requiring separate authentication flows, token structures, and database models.
|