Working state before icon/utils fixes - Oct 22

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

4
.idea/misc.xml generated
View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="Black"> <component name="Black">
<option name="sdkName" value="Python 3.10 (Letzshop-Import)" /> <option name="sdkName" value="Python 3.13 (fastapi-multitenant-ecommerce)" />
</component> </component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10 (Letzshop-Import)" project-jdk-type="Python SDK" /> <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13 (fastapi-multitenant-ecommerce)" project-jdk-type="Python SDK" />
</project> </project>

2
.idea/modules.xml generated
View File

@@ -2,7 +2,7 @@
<project version="4"> <project version="4">
<component name="ProjectModuleManager"> <component name="ProjectModuleManager">
<modules> <modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Letzshop-Import.iml" filepath="$PROJECT_DIR$/.idea/Letzshop-Import.iml" /> <module fileurl="file://$PROJECT_DIR$/.idea/fastapi-multitenant-ecommerce.iml" filepath="$PROJECT_DIR$/.idea/fastapi-multitenant-ecommerce.iml" />
</modules> </modules>
</component> </component>
</project> </project>

View File

@@ -233,7 +233,7 @@ pip install -r requirements.txt
```bash ```bash
# Create database # Create database
createdb letzvendor_db createdb ecommerce_db
# Run migrations # Run migrations
python scripts/init_db.py python scripts/init_db.py
@@ -252,7 +252,7 @@ cp .env.example .env
Minimal `.env`: Minimal `.env`:
```env ```env
DATABASE_URL=postgresql://user:pass@localhost:5432/letzvendor_db DATABASE_URL=postgresql://user:pass@localhost:5432/ecommerce_db
SECRET_KEY=your-secret-key-here-generate-with-openssl SECRET_KEY=your-secret-key-here-generate-with-openssl
ALGORITHM=HS256 ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30 ACCESS_TOKEN_EXPIRE_MINUTES=30

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,399 @@
# Work Plan - October 22, 2025
## Jinja2 Migration: Polish & Complete Admin Panel
**Current Status:** Core migration complete ✅ | Auth loop fixed ✅ | Minor issues remaining ⚠️
---
## 🎯 Today's Goals
1. ✅ Fix icon system and utils.js conflicts
2. ✅ Test and verify logout flow
3. ✅ Test all admin pages (vendors, users)
4. ✅ Create remaining templates
5. ✅ Clean up and remove old code
**Estimated Time:** 3-4 hours
---
## 📋 Task List
### Priority 1: Fix Icon/Utils Conflicts (HIGH) ⚠️
**Issue Reported:**
> "Share some outputs about $icons issues and utils already declared"
#### Task 1.1: Investigate Icon Issues
- [ ] Check browser console for icon-related errors
- [ ] Verify `icons.js` is loaded only once
- [ ] Check for duplicate `window.icon` declarations
- [ ] Test icon rendering in all templates
**Files to Check:**
- `static/shared/js/icons.js`
- `app/templates/admin/base.html` (script order)
- `app/templates/admin/login.html` (script order)
**Expected Issues:**
```javascript
// Possible duplicate declaration
Uncaught SyntaxError: Identifier 'icon' has already been declared
// or
Warning: window.icon is already defined
```
**Fix:**
- Ensure `icons.js` loaded only once per page
- Remove any duplicate `icon()` function declarations
- Verify Alpine magic helper `$icon()` is registered correctly
#### Task 1.2: Investigate Utils Issues
- [ ] Check for duplicate `Utils` object declarations
- [ ] Verify `utils.js` loaded only once
- [ ] Test all utility functions (formatDate, showToast, etc.)
**Files to Check:**
- `static/shared/js/utils.js`
- `static/shared/js/api-client.js` (Utils defined here too?)
**Potential Fix:**
```javascript
// Option 1: Use namespace to avoid conflicts
if (typeof window.Utils === 'undefined') {
window.Utils = { /* ... */ };
}
// Option 2: Remove duplicate definitions
// Keep Utils only in one place (either utils.js OR api-client.js)
```
---
### Priority 2: Test Logout Flow (HIGH) 🔐
#### Task 2.1: Test Logout Button
- [ ] Click logout in header
- [ ] Verify cookie is deleted
- [ ] Verify localStorage is cleared
- [ ] Verify redirect to login page
- [ ] Verify cannot access dashboard after logout
**Test Script:**
```javascript
// Before logout
console.log('Cookie:', document.cookie);
console.log('localStorage:', localStorage.getItem('admin_token'));
// Click logout
// After logout (should be empty)
console.log('Cookie:', document.cookie); // Should not contain admin_token
console.log('localStorage:', localStorage.getItem('admin_token')); // Should be null
```
#### Task 2.2: Update Logout Endpoint (if needed)
**File:** `app/api/v1/admin/auth.py`
Already implemented, just verify:
```python
@router.post("/logout")
def admin_logout(response: Response):
# Clears the cookie
response.delete_cookie(key="admin_token", path="/")
return {"message": "Logged out successfully"}
```
#### Task 2.3: Update Header Logout Button
**File:** `app/templates/partials/header.html`
Verify logout button calls the correct endpoint:
```html
<button @click="handleLogout()">
Logout
</button>
<script>
function handleLogout() {
// Call logout API
fetch('/api/v1/admin/auth/logout', { method: 'POST' })
.then(() => {
// Clear localStorage
localStorage.clear();
// Redirect
window.location.href = '/admin/login';
});
}
</script>
```
---
### Priority 3: Test All Admin Pages (MEDIUM) 📄
#### Task 3.1: Test Vendors Page
- [ ] Navigate to `/admin/vendors`
- [ ] Verify page loads with authentication
- [ ] Check if template exists or needs creation
- [ ] Test vendor list display
- [ ] Test vendor creation button
**If template missing:**
Create `app/templates/admin/vendors.html`
#### Task 3.2: Test Users Page
- [ ] Navigate to `/admin/users`
- [ ] Verify page loads with authentication
- [ ] Check if template exists or needs creation
- [ ] Test user list display
**If template missing:**
Create `app/templates/admin/users.html`
#### Task 3.3: Test Navigation
- [ ] Click all sidebar links
- [ ] Verify no 404 errors
- [ ] Verify active state highlights correctly
- [ ] Test breadcrumbs (if applicable)
---
### Priority 4: Create Missing Templates (MEDIUM) 📝
#### Task 4.1: Create Vendors Template
**File:** `app/templates/admin/vendors.html`
```jinja2
{% extends "admin/base.html" %}
{% block title %}Vendors Management{% endblock %}
{% block alpine_data %}adminVendors(){% endblock %}
{% block content %}
<div class="my-6">
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Vendors Management
</h2>
</div>
<!-- Vendor list content -->
<div x-data="adminVendors()">
<!-- Your existing vendors.html content here -->
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/vendors.js') }}"></script>
{% endblock %}
```
#### Task 4.2: Create Users Template
**File:** `app/templates/admin/users.html`
Similar structure to vendors template.
#### Task 4.3: Verify Vendor Edit Page
Check if vendor-edit needs a template or if it's a modal/overlay.
---
### Priority 5: Cleanup (LOW) 🧹
#### Task 5.1: Remove Old Static HTML Files
- [ ] Delete `static/admin/dashboard.html` (if exists)
- [ ] Delete `static/admin/vendors.html` (if exists)
- [ ] Delete `static/admin/users.html` (if exists)
- [ ] Delete `static/admin/partials/` directory
**Before deleting:** Backup files just in case!
#### Task 5.2: Remove Partial Loader
- [ ] Delete `static/shared/js/partial-loader.js`
- [ ] Remove any references to `partialLoader` in code
- [ ] Search codebase: `grep -r "partial-loader" .`
#### Task 5.3: Clean Up frontend.py
**File:** `app/routes/frontend.py`
- [ ] Remove commented-out admin routes
- [ ] Or delete file entirely if only contained admin routes
- [ ] Update imports if needed
#### Task 5.4: Production Mode Preparation
- [ ] Set log levels to production (INFO or WARN)
- [ ] Update cookie `secure=True` for production
- [ ] Remove debug console.logs
- [ ] Test with production settings
**Update log levels:**
```javascript
// static/admin/js/log-config.js
GLOBAL_LEVEL: isDevelopment ? 4 : 2, // Debug in dev, Warnings in prod
LOGIN: isDevelopment ? 4 : 1, // Full debug in dev, errors only in prod
API_CLIENT: isDevelopment ? 3 : 1, // Info in dev, errors only in prod
```
---
## 🧪 Testing Checklist
### Comprehensive Testing
- [ ] Fresh login (clear all data first)
- [ ] Dashboard loads correctly
- [ ] Stats cards display data
- [ ] Recent vendors table works
- [ ] Sidebar navigation works
- [ ] Dark mode toggle works
- [ ] Logout clears auth and redirects
- [ ] Cannot access dashboard after logout
- [ ] Vendors page loads
- [ ] Users page loads
- [ ] No console errors
- [ ] No 404 errors in Network tab
- [ ] Icons display correctly
- [ ] All Alpine.js components work
### Browser Testing
- [ ] Chrome/Edge
- [ ] Firefox
- [ ] Safari (if available)
- [ ] Mobile view (responsive)
---
## 🐛 Debugging Guide
### If Icons Don't Display:
```javascript
// Check in console:
console.log('window.icon:', typeof window.icon);
console.log('window.Icons:', typeof window.Icons);
console.log('$icon available:', typeof Alpine !== 'undefined' && Alpine.magic('icon'));
// Test manually:
document.body.innerHTML += window.icon('home', 'w-6 h-6');
```
### If Utils Undefined:
```javascript
// Check in console:
console.log('Utils:', typeof Utils);
console.log('Utils methods:', Object.keys(Utils || {}));
// Test manually:
Utils.showToast('Test message', 'info');
```
### If Auth Fails:
```javascript
// Check storage:
console.log('localStorage token:', localStorage.getItem('admin_token'));
console.log('Cookie:', document.cookie);
// Test API manually:
fetch('/api/v1/admin/auth/me', {
headers: { 'Authorization': `Bearer ${localStorage.getItem('admin_token')}` }
}).then(r => r.json()).then(console.log);
```
---
## 📝 Documentation Tasks
### Update Documentation
- [ ] Update project README with new architecture
- [ ] Document authentication flow (cookies + localStorage)
- [ ] Document template structure
- [ ] Add deployment notes (dev vs production)
- [ ] Update API documentation if needed
### Code Comments
- [ ] Add comments to complex authentication code
- [ ] Document cookie settings and rationale
- [ ] Explain dual token storage pattern
- [ ] Add JSDoc comments to JavaScript functions
---
## 🚀 Next Phase Preview (After Today)
### Vendor Portal Migration
1. Apply same Jinja2 pattern to vendor routes
2. Create vendor templates (login, dashboard, etc.)
3. Implement vendor authentication (separate cookie: `vendor_token`)
4. Test vendor flows
### Customer/Shop Migration
1. Customer authentication system
2. Shop templates
3. Shopping cart (consider cookie vs localStorage)
4. "Remember Me" implementation
### Advanced Features
1. "Remember Me" checkbox (30-day cookies)
2. Session management
3. Multiple device logout
4. Security enhancements (CSRF tokens)
---
## ⏰ Time Estimates
| Task | Estimated Time | Priority |
|------|---------------|----------|
| Fix icon/utils issues | 30-45 min | HIGH |
| Test logout flow | 15-30 min | HIGH |
| Test admin pages | 30 min | MEDIUM |
| Create missing templates | 45-60 min | MEDIUM |
| Cleanup old code | 30 min | LOW |
| Testing & verification | 30-45 min | HIGH |
| Documentation | 30 min | LOW |
**Total: 3-4 hours**
---
## ✅ Success Criteria for Today
By end of day, we should have:
- [ ] All icons displaying correctly
- [ ] No JavaScript errors in console
- [ ] Logout flow working perfectly
- [ ] All admin pages accessible and working
- [ ] Templates for vendors and users pages
- [ ] Old code cleaned up
- [ ] Comprehensive testing completed
- [ ] Documentation updated
---
## 🎯 Stretch Goals (If Time Permits)
1. Add loading states to all buttons
2. Improve error messages (user-friendly)
3. Add success/error toasts to all operations
4. Implement "Remember Me" checkbox
5. Start vendor portal migration
6. Add unit tests for authentication
---
## 📞 Support Resources
### If Stuck:
- Review yesterday's complete file implementations
- Check browser console for detailed logs (log level 4)
- Use test-auth-flow.html for systematic testing
- Check Network tab for HTTP requests/responses
### Reference Files:
- `static/admin/test-auth-flow.html` - Testing interface
- `TESTING_CHECKLIST.md` - Systematic testing guide
- Yesterday's complete file updates (in conversation)
---
**Good luck with today's tasks! 🚀**
Remember: Take breaks, test

View File

@@ -0,0 +1,520 @@
# Jinja2 Migration Progress - Admin Panel
**Date:** October 20, 2025
**Project:** Multi-Tenant E-commerce Platform
**Goal:** Migrate from static HTML files to Jinja2 server-rendered templates
---
## 🎯 Current Status: DEBUGGING AUTH LOOP
We successfully set up the Jinja2 infrastructure but are experiencing authentication redirect loops. We're in the process of simplifying the auth flow to resolve this.
---
## ✅ What's Been Completed
### 1. Infrastructure Setup ✅
- [x] Added Jinja2Templates to `main.py`
- [x] Created `app/templates/` directory structure
- [x] Created `app/api/v1/admin/pages.py` for HTML routes
- [x] Integrated pages router into the main app
**Files Created:**
```
app/
├── templates/
│ ├── admin/
│ │ ├── base.html ✅ Created
│ │ ├── login.html ✅ Created
│ │ └── dashboard.html ✅ Created
│ └── partials/
│ ├── header.html ✅ Moved from static
│ └── sidebar.html ✅ Moved from static
└── api/
└── v1/
└── admin/
└── pages.py ✅ Created
```
### 2. Route Configuration ✅
**New Jinja2 Routes (working):**
- `/admin/` → redirects to `/admin/dashboard`
- `/admin/login` → login page (no auth)
- `/admin/dashboard` → dashboard page (requires auth)
- `/admin/vendors` → vendors page (requires auth)
- `/admin/users` → users page (requires auth)
**Old Static Routes (disabled):**
- Commented out admin routes in `app/routes/frontend.py`
- Old `/static/admin/*.html` routes no longer active
### 3. Exception Handler Updates ✅
- [x] Updated `app/exceptions/handler.py` to redirect HTML requests on 401
- [x] Added `_is_html_page_request()` helper function
- [x] Server-side redirects working for unauthenticated page access
### 4. JavaScript Updates ✅
Updated all JavaScript files to use new routes:
**Files Updated:**
- `static/admin/js/dashboard.js` - viewVendor() uses `/admin/vendors`
- `static/admin/js/login.js` - redirects to `/admin/dashboard`
- `static/admin/js/vendors.js` - auth checks use `/admin/login`
- `static/admin/js/vendor-edit.js` - all redirects updated
- `static/shared/js/api-client.js` - handleUnauthorized() uses `/admin/login`
### 5. Template Structure ✅
**Base Template (`app/templates/admin/base.html`):**
- Server-side includes for header and sidebar (no more AJAX loading!)
- Proper script loading order
- Alpine.js integration
- No more `partial-loader.js`
**Dashboard Template (`app/templates/admin/dashboard.html`):**
- Extends base template
- Uses Alpine.js `adminDashboard()` component
- Stats cards and recent vendors table
**Login Template (`app/templates/admin/login.html`):**
- Standalone page (doesn't extend base)
- Uses Alpine.js `adminLogin()` component
---
## ❌ Current Problem: Authentication Loop
### Issue Description
Getting infinite redirect loops in various scenarios:
1. After login → redirects back to login
2. On login page → continuous API calls to `/admin/auth/me`
3. Dashboard → redirects to login → redirects to dashboard
### Root Causes Identified
1. **Multiple redirect handlers fighting:**
- Server-side: `handler.py` redirects on 401 for HTML pages
- Client-side: `api-client.js` also redirects on 401
- Both triggering simultaneously
2. **Login page checking auth on init:**
- Calls `/admin/auth/me` on page load
- Gets 401 → triggers redirect
- Creates loop
3. **Token not being sent properly:**
- Token stored but API calls not including it
- Gets 401 even with valid token
### Latest Approach (In Progress)
Simplifying to minimal working version:
- Login page does NOTHING on init (no auth checking)
- API client does NOT redirect (just throws errors)
- Server ONLY redirects browser HTML requests (not API calls)
- One source of truth for auth handling
---
## 📝 Files Modified (Complete List)
### Backend Files
1. **`main.py`**
```python
# Added:
- Jinja2Templates import and configuration
- admin_pages router include at /admin prefix
```
2. **`app/api/main.py`** (unchanged - just includes v1 routes)
3. **`app/api/v1/admin/__init__.py`**
```python
# Added:
- import pages
- router.include_router(pages.router, tags=["admin-pages"])
```
4. **`app/api/v1/admin/pages.py`** (NEW FILE)
```python
# Contains:
- @router.get("/") - root redirect
- @router.get("/login") - login page
- @router.get("/dashboard") - dashboard page
- @router.get("/vendors") - vendors page
- @router.get("/users") - users page
```
5. **`app/routes/frontend.py`**
```python
# Changed:
- Commented out all /admin/ routes
- Left vendor and shop routes active
```
6. **`app/exceptions/handler.py`**
```python
# Added:
- 401 redirect logic for HTML pages
- _is_html_page_request() helper
# Status: Needs simplification
```
### Frontend Files
1. **`static/admin/js/login.js`**
```javascript
// Changed:
- Removed /static/admin/ paths
- Updated to /admin/ paths
- checkExistingAuth() logic
# Status: Needs simplification
```
2. **`static/admin/js/dashboard.js`**
```javascript
// Changed:
- viewVendor() uses /admin/vendors
# Status: Working
```
3. **`static/admin/js/vendors.js`**
```javascript
// Changed:
- checkAuth() redirects to /admin/login
- handleLogout() redirects to /admin/login
# Status: Not tested yet
```
4. **`static/admin/js/vendor-edit.js`**
```javascript
// Changed:
- All /static/admin/ paths to /admin/
# Status: Not tested yet
```
5. **`static/shared/js/api-client.js`**
```javascript
// Changed:
- handleUnauthorized() uses /admin/login
# Status: Needs simplification - causing loops
```
6. **`static/shared/js/utils.js`** (unchanged - working fine)
### Template Files (NEW)
1. **`app/templates/admin/base.html`** ✅
- Master layout with sidebar and header
- Script loading in correct order
- No partial-loader.js
2. **`app/templates/admin/login.html`** ✅
- Standalone login page
- Alpine.js adminLogin() component
3. **`app/templates/admin/dashboard.html`** ✅
- Extends base.html
- Alpine.js adminDashboard() component
4. **`app/templates/partials/header.html`** ✅
- Top navigation bar
- Updated logout link to /admin/login
5. **`app/templates/partials/sidebar.html`** ✅
- Side navigation menu
- Updated all links to /admin/* paths
---
## 🔧 Next Steps (Tomorrow)
### Immediate Priority: Fix Auth Loop
Apply the simplified approach from the last message:
1. **Simplify `login.js`:**
```javascript
// Remove all auth checking on init
// Just show login form
// Only redirect after successful login
```
2. **Simplify `api-client.js`:**
```javascript
// Remove handleUnauthorized() redirect logic
// Just throw errors, don't redirect
// Let server handle redirects
```
3. **Simplify `handler.py`:**
```javascript
// Only redirect browser HTML requests (text/html accept header)
// Don't redirect API calls (application/json)
// Don't redirect if already on login page
```
**Test Flow:**
1. Navigate to `/admin/login` → should show form (no loops)
2. Login → should redirect to `/admin/dashboard`
3. Dashboard → should load with sidebar/header
4. No console errors, no 404s for partials
### After Auth Works
1. **Create remaining page templates:**
- `app/templates/admin/vendors.html`
- `app/templates/admin/users.html`
- `app/templates/admin/vendor-edit.html`
2. **Test all admin flows:**
- Login ✓
- Dashboard ✓
- Vendors list
- Vendor create
- Vendor edit
- User management
3. **Cleanup:**
- Remove old static HTML files
- Remove `app/routes/frontend.py` admin routes completely
- Remove `partial-loader.js`
4. **Migrate vendor portal:**
- Same process for `/vendor/*` routes
- Create vendor templates
- Update vendor JavaScript files
---
## 📚 Key Learnings
### What Worked
1. ✅ **Server-side template rendering** - Clean, fast, no AJAX for partials
2. ✅ **Jinja2 integration** - Easy to set up, works with FastAPI
3. ✅ **Route separation** - HTML routes in `pages.py`, API routes separate
4. ✅ **Template inheritance** - `base.html` + `{% extends %}` pattern
### What Caused Issues
1. ❌ **Multiple redirect handlers** - Client + server both handling 401
2. ❌ **Auth checking on login page** - Created loops
3. ❌ **Complex error handling** - Too many places making decisions
4. ❌ **Path inconsistencies** - Old `/static/admin/` vs new `/admin/`
### Best Practices Identified
1. **Single source of truth for redirects** - Choose server OR client, not both
2. **Login page should be dumb** - No auth checking, just show form
3. **API client should be simple** - Fetch data, throw errors, don't redirect
4. **Server handles page-level auth** - FastAPI dependencies + exception handler
5. **Clear separation** - HTML pages vs API endpoints
---
## 🗂️ Project Structure (Current)
```
project/
├── main.py ✅ Updated
├── app/
│ ├── api/
│ │ ├── main.py ✅ Unchanged
│ │ └── v1/
│ │ └── admin/
│ │ ├── __init__.py ✅ Updated
│ │ ├── pages.py ✅ NEW
│ │ ├── auth.py ✅ Existing (API routes)
│ │ ├── vendors.py ✅ Existing (API routes)
│ │ └── dashboard.py ✅ Existing (API routes)
│ ├── routes/
│ │ └── frontend.py ⚠️ Partially disabled
│ ├── exceptions/
│ │ └── handler.py ⚠️ Needs simplification
│ └── templates/ ✅ NEW
│ ├── admin/
│ │ ├── base.html
│ │ ├── login.html
│ │ └── dashboard.html
│ └── partials/
│ ├── header.html
│ └── sidebar.html
└── static/
├── admin/
│ ├── js/
│ │ ├── login.js ⚠️ Needs simplification
│ │ ├── dashboard.js ✅ Updated
│ │ ├── vendors.js ✅ Updated
│ │ └── vendor-edit.js ✅ Updated
│ └── css/
│ └── tailwind.output.css ✅ Unchanged
└── shared/
└── js/
├── api-client.js ⚠️ Needs simplification
├── utils.js ✅ Working
└── icons.js ✅ Working
```
**Legend:**
- ✅ = Working correctly
- ⚠️ = Needs attention/debugging
- ❌ = Not working/causing issues
---
## 🐛 Debug Commands
### Clear localStorage (Browser Console)
```javascript
localStorage.clear();
```
### Check stored tokens
```javascript
console.log('admin_token:', localStorage.getItem('admin_token'));
console.log('admin_user:', localStorage.getItem('admin_user'));
```
### Test API call manually
```javascript
fetch('/api/v1/admin/auth/me', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('admin_token')}`
}
}).then(r => r.json()).then(d => console.log(d));
```
### Check current route
```javascript
console.log('Current path:', window.location.pathname);
console.log('Full URL:', window.location.href);
```
---
## 📖 Reference: Working Code Snippets
### Minimal Login.js (To Try Tomorrow)
```javascript
function adminLogin() {
return {
dark: false,
credentials: { username: '', password: '' },
loading: false,
error: null,
success: null,
errors: {},
init() {
this.dark = localStorage.getItem('theme') === 'dark';
// NO AUTH CHECKING - just show form
},
async handleLogin() {
if (!this.validateForm()) return;
this.loading = true;
try {
const response = await fetch('/api/v1/admin/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: this.credentials.username,
password: this.credentials.password
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.message);
localStorage.setItem('admin_token', data.access_token);
localStorage.setItem('admin_user', JSON.stringify(data.user));
this.success = 'Login successful!';
setTimeout(() => window.location.href = '/admin/dashboard', 500);
} catch (error) {
this.error = error.message;
} finally {
this.loading = false;
}
}
}
}
```
### Simplified API Client Request Method
```javascript
async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;
const config = {
...options,
headers: this.getHeaders(options.headers)
};
const response = await fetch(url, config);
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Request failed');
}
return data;
// NO REDIRECT LOGIC HERE!
}
```
### Simplified Exception Handler
```python
if exc.status_code == 401:
accept_header = request.headers.get("accept", "")
is_browser = "text/html" in accept_header
if is_browser and not request.url.path.endswith("/login"):
if request.url.path.startswith("/admin"):
return RedirectResponse(url="/admin/login", status_code=302)
# Return JSON for API calls
return JSONResponse(status_code=exc.status_code, content=exc.to_dict())
```
---
## 💡 Questions to Answer Tomorrow
1. Does the simplified auth flow work without loops?
2. Can we successfully login and access dashboard?
3. Are tokens being sent correctly in API requests?
4. Do we need the auth check on login page at all?
5. Should we move ALL redirect logic to server-side?
---
## 🎯 Success Criteria
The migration will be considered successful when:
- [ ] Login page loads without loops
- [ ] Login succeeds and redirects to dashboard
- [ ] Dashboard displays with sidebar and header
- [ ] No 404 errors for partials
- [ ] Icons display correctly
- [ ] Stats cards load data from API
- [ ] Navigation between admin pages works
- [ ] Logout works correctly
---
**End of Session - October 20, 2025**
Good work today! We made significant progress on the infrastructure. Tomorrow we'll resolve the auth loop and complete the admin panel migration.

649
admin_integration_guide.md Normal file
View File

@@ -0,0 +1,649 @@
# Admin Models Integration Guide
## What We've Added
You now have:
1. **Database Models** (`models/database/admin.py`):
- `AdminAuditLog` - Track all admin actions
- `AdminNotification` - System alerts for admins
- `AdminSetting` - Platform-wide settings
- `PlatformAlert` - System health alerts
- `AdminSession` - Track admin login sessions
2. **Pydantic Schemas** (`models/schemas/admin.py`):
- Request/response models for all admin operations
- Validation for bulk operations
- System health check schemas
3. **Services**:
- `AdminAuditService` - Audit logging operations
- `AdminSettingsService` - Platform settings management
4. **API Endpoints**:
- `/api/v1/admin/audit` - Audit log endpoints
- `/api/v1/admin/settings` - Settings management
- `/api/v1/admin/notifications` - Notifications & alerts (stubs)
---
## Step-by-Step Integration
### Step 1: Update Database
Add the new models to your database imports:
```python
# models/database/__init__.py
from .admin import (
AdminAuditLog,
AdminNotification,
AdminSetting,
PlatformAlert,
AdminSession
)
```
Run database migration:
```bash
# Create migration
alembic revision --autogenerate -m "Add admin models"
# Apply migration
alembic upgrade head
```
### Step 2: Update Admin API Router
```python
# app/api/v1/admin/__init__.py
from fastapi import APIRouter
from . import auth, vendors, users, dashboard, marketplace, audit, settings, notifications
router = APIRouter(prefix="/admin", tags=["admin"])
# Include all admin routers
router.include_router(auth.router)
router.include_router(vendors.router)
router.include_router(users.router)
router.include_router(dashboard.router)
router.include_router(marketplace.router)
router.include_router(audit.router) # NEW
router.include_router(settings.router) # NEW
router.include_router(notifications.router) # NEW
```
### Step 3: Add Audit Logging to Existing Admin Operations
Update your `admin_service.py` to log actions:
```python
# app/services/admin_service.py
from app.services.admin_audit_service import admin_audit_service
class AdminService:
def create_vendor_with_owner(
self, db: Session, vendor_data: VendorCreate
) -> Tuple[Vendor, User, str]:
"""Create vendor with owner user account."""
# ... existing code ...
vendor, owner_user, temp_password = # ... your creation logic
# LOG THE ACTION
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin_id, # You'll need to pass this
action="create_vendor",
target_type="vendor",
target_id=str(vendor.id),
details={
"vendor_code": vendor.vendor_code,
"subdomain": vendor.subdomain,
"owner_email": owner_user.email
}
)
return vendor, owner_user, temp_password
def toggle_vendor_status(
self, db: Session, vendor_id: int, admin_user_id: int
) -> Tuple[Vendor, str]:
"""Toggle vendor status with audit logging."""
vendor = self._get_vendor_by_id_or_raise(db, vendor_id)
old_status = vendor.is_active
# ... toggle logic ...
# LOG THE ACTION
admin_audit_service.log_action(
db=db,
admin_user_id=admin_user_id,
action="toggle_vendor_status",
target_type="vendor",
target_id=str(vendor_id),
details={
"old_status": "active" if old_status else "inactive",
"new_status": "active" if vendor.is_active else "inactive"
}
)
return vendor, message
```
### Step 4: Update API Endpoints to Pass Admin User ID
Your API endpoints need to pass the current admin's ID to service methods:
```python
# app/api/v1/admin/vendors.py
@router.post("", response_model=VendorResponse)
def create_vendor_with_owner(
vendor_data: VendorCreate,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Create vendor with audit logging."""
vendor, owner_user, temp_password = admin_service.create_vendor_with_owner(
db=db,
vendor_data=vendor_data,
admin_user_id=current_admin.id # Pass admin ID for audit logging
)
# Audit log is automatically created inside the service
return {
**VendorResponse.model_validate(vendor).model_dump(),
"owner_email": owner_user.email,
"owner_username": owner_user.username,
"temporary_password": temp_password,
"login_url": f"{vendor.subdomain}.platform.com/vendor/login"
}
@router.put("/{vendor_id}/status")
def toggle_vendor_status(
vendor_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Toggle vendor status with audit logging."""
vendor, message = admin_service.toggle_vendor_status(
db=db,
vendor_id=vendor_id,
admin_user_id=current_admin.id # Pass for audit
)
return {"message": message, "vendor": VendorResponse.model_validate(vendor)}
```
### Step 5: Add Request Context to Audit Logs
To capture IP address and user agent, use FastAPI's Request object:
```python
# app/api/v1/admin/vendors.py
from fastapi import Request
@router.delete("/{vendor_id}")
def delete_vendor(
vendor_id: int,
request: Request, # Add request parameter
confirm: bool = Query(False),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Delete vendor with full audit trail."""
if not confirm:
raise HTTPException(status_code=400, detail="Confirmation required")
# Get request metadata
ip_address = request.client.host if request.client else None
user_agent = request.headers.get("user-agent")
message = admin_service.delete_vendor(db, vendor_id)
# Log with full context
admin_audit_service.log_action(
db=db,
admin_user_id=current_admin.id,
action="delete_vendor",
target_type="vendor",
target_id=str(vendor_id),
ip_address=ip_address,
user_agent=user_agent,
details={"confirm": True}
)
return {"message": message}
```
---
## Example: Platform Settings Usage
### Creating Default Settings
```python
# scripts/init_platform_settings.py
from app.core.database import SessionLocal
from app.services.admin_settings_service import admin_settings_service
from models.schemas.admin import AdminSettingCreate
db = SessionLocal()
# Create default platform settings
settings = [
AdminSettingCreate(
key="max_vendors_allowed",
value="1000",
value_type="integer",
category="system",
description="Maximum number of vendors allowed on the platform",
is_public=False
),
AdminSettingCreate(
key="maintenance_mode",
value="false",
value_type="boolean",
category="system",
description="Enable maintenance mode (blocks all non-admin access)",
is_public=True
),
AdminSettingCreate(
key="vendor_trial_days",
value="30",
value_type="integer",
category="system",
description="Default trial period for new vendors (days)",
is_public=False
),
AdminSettingCreate(
key="stripe_publishable_key",
value="pk_test_...",
value_type="string",
category="payments",
description="Stripe publishable key",
is_public=True
),
AdminSettingCreate(
key="stripe_secret_key",
value="sk_test_...",
value_type="string",
category="payments",
description="Stripe secret key",
is_encrypted=True,
is_public=False
)
]
for setting_data in settings:
try:
admin_settings_service.upsert_setting(db, setting_data, admin_user_id=1)
print(f"✓ Created setting: {setting_data.key}")
except Exception as e:
print(f"✗ Failed to create {setting_data.key}: {e}")
db.close()
```
### Using Settings in Your Code
```python
# app/services/vendor_service.py
from app.services.admin_settings_service import admin_settings_service
def can_create_vendor(db: Session) -> bool:
"""Check if platform allows creating more vendors."""
max_vendors = admin_settings_service.get_setting_value(
db=db,
key="max_vendors_allowed",
default=1000
)
current_count = db.query(Vendor).count()
return current_count < max_vendors
def is_maintenance_mode(db: Session) -> bool:
"""Check if platform is in maintenance mode."""
return admin_settings_service.get_setting_value(
db=db,
key="maintenance_mode",
default=False
)
```
---
## Frontend Integration
### Admin Dashboard with Audit Logs
```html
<!-- templates/admin/audit_logs.html -->
<div x-data="auditLogs()" x-init="loadLogs()">
<h1>Audit Logs</h1>
<!-- Filters -->
<div class="filters">
<select x-model="filters.action" @change="loadLogs()">
<option value="">All Actions</option>
<option value="create_vendor">Create Vendor</option>
<option value="delete_vendor">Delete Vendor</option>
<option value="toggle_vendor_status">Toggle Status</option>
<option value="update_setting">Update Setting</option>
</select>
<select x-model="filters.target_type" @change="loadLogs()">
<option value="">All Targets</option>
<option value="vendor">Vendors</option>
<option value="user">Users</option>
<option value="setting">Settings</option>
</select>
</div>
<!-- Logs Table -->
<table class="data-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Admin</th>
<th>Action</th>
<th>Target</th>
<th>Details</th>
<th>IP Address</th>
</tr>
</thead>
<tbody>
<template x-for="log in logs" :key="log.id">
<tr>
<td x-text="formatDate(log.created_at)"></td>
<td x-text="log.admin_username"></td>
<td>
<span class="badge" x-text="log.action"></span>
</td>
<td x-text="`${log.target_type}:${log.target_id}`"></td>
<td>
<button @click="showDetails(log)">View</button>
</td>
<td x-text="log.ip_address"></td>
</tr>
</template>
</tbody>
</table>
<!-- Pagination -->
<div class="pagination">
<button @click="previousPage()" :disabled="skip === 0">Previous</button>
<span x-text="`Page ${currentPage} of ${totalPages}`"></span>
<button @click="nextPage()" :disabled="!hasMore">Next</button>
</div>
</div>
<script>
function auditLogs() {
return {
logs: [],
filters: {
action: '',
target_type: '',
admin_user_id: null
},
skip: 0,
limit: 50,
total: 0,
async loadLogs() {
const params = new URLSearchParams({
skip: this.skip,
limit: this.limit,
...this.filters
});
const response = await apiClient.get(`/api/v1/admin/audit/logs?${params}`);
this.logs = response.logs;
this.total = response.total;
},
showDetails(log) {
// Show modal with full details
console.log('Details:', log.details);
},
formatDate(date) {
return new Date(date).toLocaleString();
},
get currentPage() {
return Math.floor(this.skip / this.limit) + 1;
},
get totalPages() {
return Math.ceil(this.total / this.limit);
},
get hasMore() {
return this.skip + this.limit < this.total;
},
nextPage() {
this.skip += this.limit;
this.loadLogs();
},
previousPage() {
this.skip = Math.max(0, this.skip - this.limit);
this.loadLogs();
}
}
}
</script>
```
### Platform Settings Management
```html
<!-- templates/admin/settings.html -->
<div x-data="platformSettings()" x-init="loadSettings()">
<h1>Platform Settings</h1>
<!-- Category Tabs -->
<div class="tabs">
<button
@click="selectedCategory = 'system'"
:class="{'active': selectedCategory === 'system'}"
>System</button>
<button
@click="selectedCategory = 'security'"
:class="{'active': selectedCategory === 'security'}"
>Security</button>
<button
@click="selectedCategory = 'payments'"
:class="{'active': selectedCategory === 'payments'}"
>Payments</button>
</div>
<!-- Settings List -->
<div class="settings-list">
<template x-for="setting in filteredSettings" :key="setting.id">
<div class="setting-item">
<div class="setting-header">
<h3 x-text="setting.key"></h3>
<span class="badge" x-text="setting.value_type"></span>
</div>
<p class="setting-description" x-text="setting.description"></p>
<div class="setting-value">
<input
type="text"
:value="setting.value"
@change="updateSetting(setting.key, $event.target.value)"
>
<span class="updated-at" x-text="`Updated: ${formatDate(setting.updated_at)}`"></span>
</div>
</div>
</template>
</div>
<!-- Add New Setting -->
<button @click="showAddModal = true" class="btn-primary">
Add New Setting
</button>
</div>
<script>
function platformSettings() {
return {
settings: [],
selectedCategory: 'system',
showAddModal: false,
async loadSettings() {
const response = await apiClient.get('/api/v1/admin/settings');
this.settings = response.settings;
},
get filteredSettings() {
if (!this.selectedCategory) return this.settings;
return this.settings.filter(s => s.category === this.selectedCategory);
},
async updateSetting(key, newValue) {
try {
await apiClient.put(`/api/v1/admin/settings/${key}`, {
value: newValue
});
showNotification('Setting updated successfully', 'success');
this.loadSettings();
} catch (error) {
showNotification('Failed to update setting', 'error');
}
},
formatDate(date) {
return new Date(date).toLocaleString();
}
}
}
</script>
```
---
## Testing the New Features
### Test Audit Logging
```python
# tests/test_admin_audit.py
import pytest
from app.services.admin_audit_service import admin_audit_service
def test_log_admin_action(db_session, test_admin_user):
"""Test logging admin actions."""
log = admin_audit_service.log_action(
db=db_session,
admin_user_id=test_admin_user.id,
action="create_vendor",
target_type="vendor",
target_id="123",
details={"vendor_code": "TEST"}
)
assert log is not None
assert log.action == "create_vendor"
assert log.target_type == "vendor"
assert log.details["vendor_code"] == "TEST"
def test_query_audit_logs(db_session, test_admin_user):
"""Test querying audit logs with filters."""
# Create test logs
for i in range(5):
admin_audit_service.log_action(
db=db_session,
admin_user_id=test_admin_user.id,
action=f"test_action_{i}",
target_type="test",
target_id=str(i)
)
# Query logs
from models.schemas.admin import AdminAuditLogFilters
filters = AdminAuditLogFilters(limit=10)
logs = admin_audit_service.get_audit_logs(db_session, filters)
assert len(logs) == 5
```
### Test Platform Settings
```python
# tests/test_admin_settings.py
def test_create_setting(db_session, test_admin_user):
"""Test creating platform setting."""
from models.schemas.admin import AdminSettingCreate
setting_data = AdminSettingCreate(
key="test_setting",
value="test_value",
value_type="string",
category="test"
)
result = admin_settings_service.create_setting(
db=db_session,
setting_data=setting_data,
admin_user_id=test_admin_user.id
)
assert result.key == "test_setting"
assert result.value == "test_value"
def test_get_setting_value_with_type_conversion(db_session):
"""Test getting setting values with proper type conversion."""
# Create integer setting
setting_data = AdminSettingCreate(
key="max_vendors",
value="100",
value_type="integer",
category="system"
)
admin_settings_service.create_setting(db_session, setting_data, 1)
# Get value (should be converted to int)
value = admin_settings_service.get_setting_value(db_session, "max_vendors")
assert isinstance(value, int)
assert value == 100
```
---
## Summary
You now have a complete admin infrastructure with:
**Audit Logging**: Track all admin actions for compliance
**Platform Settings**: Manage global configuration
**Notifications**: System alerts for admins (structure ready)
**Platform Alerts**: Health monitoring (structure ready)
**Session Tracking**: Monitor admin logins (structure ready)
### Next Steps
1. **Apply database migrations** to create new tables
2. **Update admin API router** to include new endpoints
3. **Add audit logging** to existing admin operations
4. **Create default platform settings** using the script
5. **Build frontend pages** for audit logs and settings
6. **Implement notification service** (notifications.py stubs)
7. **Add monitoring** for platform alerts
These additions make your platform production-ready with full compliance and monitoring capabilities!

View File

@@ -1,13 +1,18 @@
# app/api/deps.py # app/api/deps.py
"""Summary description .... """
Authentication dependencies for FastAPI routes.
This module provides classes and functions for: Implements dual token storage pattern:
- .... - Checks Authorization header first (for API calls from JavaScript)
- .... - Falls back to cookie (for browser page navigation)
- ....
This allows:
- JavaScript API calls: Use localStorage + Authorization header
- Browser page loads: Use HTTP-only cookies
""" """
from fastapi import Depends from typing import Optional
from fastapi import Depends, Request, Cookie
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -16,7 +21,12 @@ from middleware.auth import AuthManager
from middleware.rate_limiter import RateLimiter from middleware.rate_limiter import RateLimiter
from models.database.vendor import Vendor from models.database.vendor import Vendor
from models.database.user import User from models.database.user import User
from app.exceptions import (AdminRequiredException, VendorNotFoundException, UnauthorizedVendorAccessException) from app.exceptions import (
AdminRequiredException,
VendorNotFoundException,
UnauthorizedVendorAccessException,
InvalidTokenException
)
# Set auto_error=False to prevent automatic 403 responses # Set auto_error=False to prevent automatic 403 responses
security = HTTPBearer(auto_error=False) security = HTTPBearer(auto_error=False)
@@ -25,30 +35,107 @@ rate_limiter = RateLimiter()
def get_current_user( def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security), request: Request,
db: Session = Depends(get_db), credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
admin_token: Optional[str] = Cookie(None), # Check admin_token cookie
db: Session = Depends(get_db),
): ):
"""Get current authenticated user.""" """
# Check if credentials are provided Get current authenticated user.
if not credentials:
from app.exceptions.auth import InvalidTokenException
raise InvalidTokenException("Authorization header required")
return auth_manager.get_current_user(db, credentials) Checks for token in this priority order:
1. Authorization header (for API calls from JavaScript)
2. admin_token cookie (for browser page navigation)
This dual approach supports:
- API calls: JavaScript adds token from localStorage to Authorization header
- Page navigation: Browser automatically sends cookie
Args:
request: FastAPI request object
credentials: Optional Bearer token from Authorization header
admin_token: Optional token from cookie
db: Database session
Returns:
User: Authenticated user object
Raises:
InvalidTokenException: If no token found or token invalid
"""
token = None
token_source = None
# Priority 1: Authorization header (API calls from JavaScript)
if credentials:
token = credentials.credentials
token_source = "header"
# Priority 2: Cookie (browser page navigation)
elif admin_token:
token = admin_token
token_source = "cookie"
# No token found in either location
if not token:
raise InvalidTokenException("Authorization header or cookie required")
# Log token source for debugging
import logging
logger = logging.getLogger(__name__)
logger.debug(f"Token found in {token_source} for {request.url.path}")
# Create a mock credentials object for auth_manager
mock_credentials = HTTPAuthorizationCredentials(
scheme="Bearer",
credentials=token
)
return auth_manager.get_current_user(db, mock_credentials)
def get_current_admin_user(current_user: User = Depends(get_current_user)): def get_current_admin_user(current_user: User = Depends(get_current_user)):
"""Require admin user.""" """
Require admin user.
This dependency ensures the current user has admin role.
Used for protecting admin-only routes.
Args:
current_user: User object from get_current_user dependency
Returns:
User: Admin user object
Raises:
AdminRequiredException: If user is not an admin
"""
return auth_manager.require_admin(current_user) return auth_manager.require_admin(current_user)
def get_user_vendor( def get_user_vendor(
vendor_code: str, vendor_code: str,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Get vendor and verify user ownership.""" """
Get vendor and verify user ownership.
Ensures the current user has access to the specified vendor.
Admin users can access any vendor, regular users only their own.
Args:
vendor_code: Vendor code to look up
current_user: Current authenticated user
db: Database session
Returns:
Vendor: Vendor object if user has access
Raises:
VendorNotFoundException: If vendor doesn't exist
UnauthorizedVendorAccessException: If user doesn't have access
"""
vendor = db.query(Vendor).filter(Vendor.vendor_code == vendor_code.upper()).first() vendor = db.query(Vendor).filter(Vendor.vendor_code == vendor_code.upper()).first()
if not vendor: if not vendor:
raise VendorNotFoundException(vendor_code) raise VendorNotFoundException(vendor_code)
@@ -57,4 +144,3 @@ def get_user_vendor(
raise UnauthorizedVendorAccessException(vendor_code, current_user.id) raise UnauthorizedVendorAccessException(vendor_code, current_user.id)
return vendor return vendor

View File

@@ -8,9 +8,10 @@ This module combines all admin-related API endpoints:
- User management (status, roles) - User management (status, roles)
- Dashboard and statistics - Dashboard and statistics
- Marketplace monitoring - Marketplace monitoring
- Audit logging (NEW) - Audit logging
- Platform settings (NEW) - Platform settings
- Notifications and alerts (NEW) - Notifications and alerts
- HTML Pages - Server-rendered pages using Jinja2
""" """
from fastapi import APIRouter from fastapi import APIRouter
@@ -25,7 +26,8 @@ from . import (
monitoring, monitoring,
audit, audit,
settings, settings,
notifications notifications,
pages
) )
# Create admin router # Create admin router
@@ -51,7 +53,7 @@ router.include_router(marketplace.router, tags=["admin-marketplace"])
# router.include_router(monitoring.router, tags=["admin-monitoring"]) # router.include_router(monitoring.router, tags=["admin-monitoring"])
# ============================================================================ # ============================================================================
# NEW: Admin Models Integration # Admin Models Integration
# ============================================================================ # ============================================================================
# Include audit logging endpoints # Include audit logging endpoints
@@ -63,6 +65,12 @@ router.include_router(settings.router, tags=["admin-settings"])
# Include notifications and alerts endpoints # Include notifications and alerts endpoints
router.include_router(notifications.router, tags=["admin-notifications"]) router.include_router(notifications.router, tags=["admin-notifications"])
# ============================================================================
# HTML Page Routes (Jinja2 Templates)
# ============================================================================
# Include HTML page routes (these return rendered templates, not JSON)
router.include_router(pages.router, tags=["admin-pages"])
# Export the router # Export the router
__all__ = ["router"] __all__ = ["router"]

View File

@@ -2,32 +2,42 @@
""" """
Admin authentication endpoints. Admin authentication endpoints.
This module provides: Implements dual token storage:
- Admin user login - Sets HTTP-only cookie for browser page navigation
- Admin token validation - Returns token in response for localStorage (API calls)
- Admin-specific authentication logic
""" """
import logging import logging
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Response
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.auth_service import auth_service from app.services.auth_service import auth_service
from app.exceptions import InvalidCredentialsException from app.exceptions import InvalidCredentialsException
from models.schema.auth import LoginResponse, UserLogin from models.schema.auth import LoginResponse, UserLogin, UserResponse
from models.database.user import User
from app.api.deps import get_current_admin_user
from app.core.config import settings
router = APIRouter(prefix="/auth") router = APIRouter(prefix="/auth")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
def admin_login(user_credentials: UserLogin, db: Session = Depends(get_db)): def admin_login(
user_credentials: UserLogin,
response: Response,
db: Session = Depends(get_db)
):
""" """
Admin login endpoint. Admin login endpoint.
Only allows users with 'admin' role to login. Only allows users with 'admin' role to login.
Returns JWT token for authenticated admin users. Returns JWT token for authenticated admin users.
Sets token in two places:
1. HTTP-only cookie (for browser page navigation)
2. Response body (for localStorage and API calls)
""" """
# Authenticate user # 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)
@@ -39,6 +49,20 @@ def admin_login(user_credentials: UserLogin, db: Session = Depends(get_db)):
logger.info(f"Admin login successful: {login_result['user'].username}") logger.info(f"Admin login successful: {login_result['user'].username}")
# Set HTTP-only cookie for browser navigation
response.set_cookie(
key="admin_token",
value=login_result["token_data"]["access_token"],
httponly=True, # JavaScript cannot access (XSS protection)
secure=False, # Set to True in production (requires HTTPS)
samesite="lax", # CSRF protection
max_age=login_result["token_data"]["expires_in"], # Match JWT expiry
path="/", # Available for all routes
)
logger.debug(f"Set admin_token cookie with {login_result['token_data']['expires_in']}s expiry")
# Also return token in response for localStorage (API calls)
return LoginResponse( return LoginResponse(
access_token=login_result["token_data"]["access_token"], access_token=login_result["token_data"]["access_token"],
token_type=login_result["token_data"]["token_type"], token_type=login_result["token_data"]["token_type"],
@@ -47,12 +71,40 @@ def admin_login(user_credentials: UserLogin, db: Session = Depends(get_db)):
) )
@router.get("/me", response_model=UserResponse)
def get_current_admin(current_user: User = Depends(get_current_admin_user)):
"""
Get current authenticated admin user.
This endpoint validates the token and ensures the user has admin privileges.
Returns the current user's information.
Token can come from:
- Authorization header (API calls)
- admin_token cookie (browser navigation)
"""
logger.info(f"Admin user info requested: {current_user.username}")
# Pydantic will automatically serialize the User model to UserResponse
return current_user
@router.post("/logout") @router.post("/logout")
def admin_logout(): def admin_logout(response: Response):
""" """
Admin logout endpoint. Admin logout endpoint.
Client should remove token from storage. Clears the admin_token cookie.
Server-side token invalidation can be implemented here if needed. Client should also remove token from localStorage.
""" """
logger.info("Admin logout")
# Clear the cookie
response.delete_cookie(
key="admin_token",
path="/",
)
logger.debug("Deleted admin_token cookie")
return {"message": "Logged out successfully"} return {"message": "Logged out successfully"}

110
app/api/v1/admin/pages.py Normal file
View File

@@ -0,0 +1,110 @@
# app/api/v1/admin/pages.py
"""
Admin HTML page routes using Jinja2 templates.
These routes return rendered HTML pages (response_class=HTMLResponse).
Separate from other admin routes which return JSON data.
Routes:
- GET / - Admin root (redirects to login)
- GET /login - Admin login page (no auth required)
- GET /dashboard - Admin dashboard (requires auth)
- GET /vendors - Vendor management page (requires auth)
- GET /users - User management page (requires auth)
"""
from fastapi import APIRouter, Request, Depends
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_user, get_db
from models.database.user import User
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@router.get("/", response_class=RedirectResponse, include_in_schema=False)
async def admin_root():
"""
Redirect /admin/ to /admin/login.
This is the simplest approach:
- Unauthenticated users: see login form
- Authenticated users: login page clears token and shows form
(they can manually navigate to dashboard if needed)
Alternative: Could redirect to /admin/dashboard and let auth
dependency handle the redirect, but that's an extra hop.
"""
return RedirectResponse(url="/admin/login", status_code=302)
@router.get("/login", response_class=HTMLResponse, include_in_schema=False)
async def admin_login_page(request: Request):
"""
Render admin login page.
No authentication required.
"""
return templates.TemplateResponse(
"admin/login.html",
{"request": request}
)
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
async def admin_dashboard_page(
request: Request,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""
Render admin dashboard page.
Requires admin authentication - will redirect to login if not authenticated.
"""
return templates.TemplateResponse(
"admin/dashboard.html",
{
"request": request,
"user": current_user,
}
)
@router.get("/vendors", response_class=HTMLResponse, include_in_schema=False)
async def admin_vendors_page(
request: Request,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""
Render vendors management page.
Requires admin authentication.
"""
return templates.TemplateResponse(
"admin/vendors.html",
{
"request": request,
"user": current_user,
}
)
@router.get("/users", response_class=HTMLResponse, include_in_schema=False)
async def admin_users_page(
request: Request,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""
Render users management page.
Requires admin authentication.
"""
return templates.TemplateResponse(
"admin/users.html",
{
"request": request,
"user": current_user,
}
)

View File

@@ -1,10 +1,11 @@
# app/core/database.py # app/core/database.py
"""Summary description .... """
Database configuration and session management.
This module provides classes and functions for: This module provides classes and functions for:
- .... - Database engine creation and configuration
- .... - Session management with connection pooling
- .... - Database dependency for FastAPI routes
""" """
import logging import logging
@@ -21,16 +22,19 @@ Base = declarative_base()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Database dependency with connection pooling
def get_db(): def get_db():
"""Get database object.""" """
Database session dependency for FastAPI routes.
Yields a database session and ensures proper cleanup.
Handles exceptions and rolls back transactions on error.
"""
db = SessionLocal() db = SessionLocal()
try: try:
yield db yield db
except Exception as e: except Exception as e:
logger.error(f"Health check failed: {e}") logger.error(f"Database session error: {e}")
db.rollback() db.rollback()
raise raise
finally: finally:

View File

@@ -1,6 +1,6 @@
# app/exceptions/__init__.py # app/exceptions/__init__.py
""" """
Custom exception classes for the LetzVendor API. Custom exception classes for the API.
This module provides frontend-friendly exceptions with consistent error codes, This module provides frontend-friendly exceptions with consistent error codes,
messages, and HTTP status mappings. messages, and HTTP status mappings.

View File

@@ -1,6 +1,6 @@
# app/exceptions/base.py # app/exceptions/base.py
""" """
Base exception classes for the LetzVendor application. Base exception classes for the application.
This module provides classes and functions for: This module provides classes and functions for:
- Base exception class with consistent error formatting - Base exception class with consistent error formatting
@@ -12,7 +12,7 @@ from typing import Any, Dict, Optional
class LetzShopException(Exception): class LetzShopException(Exception):
"""Base exception class for all LetzVendor custom exceptions.""" """Base exception class for all custom exceptions."""
def __init__( def __init__(
self, self,

View File

@@ -14,7 +14,7 @@ from typing import Union
from fastapi import Request, HTTPException from fastapi import Request, HTTPException
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse, RedirectResponse
from .base import LetzShopException from .base import LetzShopException
@@ -26,7 +26,28 @@ def setup_exception_handlers(app):
@app.exception_handler(LetzShopException) @app.exception_handler(LetzShopException)
async def custom_exception_handler(request: Request, exc: LetzShopException): async def custom_exception_handler(request: Request, exc: LetzShopException):
"""Handle custom LetzVendor exceptions.""" """Handle custom exceptions."""
# Special handling for 401 on HTML page requests (redirect to login)
if exc.status_code == 401 and _is_html_page_request(request):
logger.info(
f"401 on HTML page request - redirecting to login: {request.url.path}",
extra={
"path": request.url.path,
"accept": request.headers.get("accept", ""),
"method": request.method
}
)
# Redirect to appropriate login page
if request.url.path.startswith("/admin"):
logger.debug("Redirecting to /admin/login")
return RedirectResponse(url="/admin/login", status_code=302)
elif "/vendor/" in request.url.path:
logger.debug("Redirecting to /vendor/login")
return RedirectResponse(url="/vendor/login", status_code=302)
# If neither, fall through to JSON response
logger.debug("No specific redirect path matched, returning JSON")
logger.error( logger.error(
f"Custom exception in {request.method} {request.url}: " f"Custom exception in {request.method} {request.url}: "
@@ -162,6 +183,51 @@ def setup_exception_handlers(app):
} }
) )
def _is_html_page_request(request: Request) -> bool:
"""
Check if the request is for an HTML page (not an API endpoint).
More precise detection:
- Must NOT have /api/ in path
- Must be GET request
- Must explicitly accept text/html
- Must not already be on login page
"""
logger.debug(
f"Checking if HTML page request: {request.url.path}",
extra={
"path": request.url.path,
"method": request.method,
"accept": request.headers.get("accept", "")
}
)
# Don't redirect API calls
if "/api/" in request.url.path:
logger.debug("Not HTML page: API endpoint")
return False
# Don't redirect if already on login page
if request.url.path.endswith("/login"):
logger.debug("Not HTML page: Already on login page")
return False
# Only redirect GET requests (page loads)
if request.method != "GET":
logger.debug(f"Not HTML page: Method is {request.method}, not GET")
return False
# MUST explicitly accept HTML (strict check)
accept_header = request.headers.get("accept", "")
if "text/html" not in accept_header:
logger.debug(f"Not HTML page: Accept header doesn't include text/html: {accept_header}")
return False
logger.debug("IS HTML page request - will redirect on 401")
return True
# Utility functions for common exception scenarios # Utility functions for common exception scenarios
def raise_not_found(resource_type: str, identifier: str) -> None: def raise_not_found(resource_type: str, identifier: str) -> None:
"""Convenience function to raise ResourceNotFoundException.""" """Convenience function to raise ResourceNotFoundException."""

View File

@@ -13,31 +13,31 @@ router = APIRouter(include_in_schema=False)
# ============================================================================ # ============================================================================
# ADMIN ROUTES # ADMIN ROUTES - DISABLED (Now using Jinja2 templates in pages.py)
# ============================================================================ # ============================================================================
@router.get("/admin/") # @router.get("/admin/")
@router.get("/admin/login") # @router.get("/admin/login")
async def admin_login(): # async def admin_login():
"""Serve admin login page""" # """Serve admin login page"""
return FileResponse("static/admin/login.html") # return FileResponse("static/admin/login.html")
@router.get("/admin/dashboard") # @router.get("/admin/dashboard")
async def admin_dashboard(): # async def admin_dashboard():
"""Serve admin dashboard page""" # """Serve admin dashboard page"""
return FileResponse("static/admin/dashboard.html") # return FileResponse("static/admin/dashboard.html")
@router.get("/admin/vendors") # @router.get("/admin/vendors")
async def admin_vendors(): # async def admin_vendors():
"""Serve admin vendors management page""" # """Serve admin vendors management page"""
return FileResponse("static/admin/vendors.html") # return FileResponse("static/admin/vendors.html")
@router.get("/admin/vendor-edit") # @router.get("/admin/vendor-edit")
async def admin_vendor_edit(): # async def admin_vendor_edit():
"""Serve admin vendor edit page""" # """Serve admin vendor edit page"""
return FileResponse("static/admin/vendor-edit.html") # return FileResponse("static/admin/vendor-edit.html")
# ============================================================================ # ============================================================================
# VENDOR ROUTES (with vendor code in path) # VENDOR ROUTES (with vendor code in path)

View File

@@ -0,0 +1,60 @@
{# app/templates/admin/base.html #}
<!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}Admin Panel{% endblock %} - Multi-Tenant Platform</title>
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<!-- Tailwind CSS -->
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
<!-- Alpine Cloak -->
<style>
[x-cloak] { display: none !important; }
</style>
{% block extra_head %}{% endblock %}
</head>
<body x-cloak>
<div class="flex h-screen bg-gray-50 dark:bg-gray-900" :class="{ 'overflow-hidden': isSideMenuOpen }">
<!-- Sidebar (server-side included) -->
{% include 'partials/sidebar.html' %}
<div class="flex flex-col flex-1 w-full">
<!-- Header (server-side included) -->
{% include 'partials/header.html' %}
<!-- Main Content -->
<main class="h-full overflow-y-auto">
<div class="container px-6 mx-auto grid">
{% block content %}{% endblock %}
</div>
</main>
</div>
</div>
<!-- Core Scripts - Loaded in STRICT ORDER -->
<!-- 1. Icons FIRST (defines $icon magic) -->
<script src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<!-- 2. Base Alpine Data (defines sidebar/header state) -->
<script src="{{ url_for('static', path='admin/js/init-alpine.js') }}"></script>
<!-- 3. API Client -->
<script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<!-- 4. Utils -->
<script src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
<!-- 5. Alpine.js v3 (deferred to allow DOM to load) -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
<!-- 6. Page-specific scripts -->
{% block extra_scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,172 @@
{# app/templates/admin/dashboard.html #}
{% extends "admin/base.html" %}
{% block title %}Dashboard{% endblock %}
{% block alpine_data %}adminDashboard(){% endblock %}
{% block content %}
<!-- Page Header with Refresh Button -->
<div class="flex items-center justify-between my-6">
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Dashboard
</h2>
<button
@click="refresh()"
:disabled="loading"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
</button>
</div>
<!-- Loading State -->
<div x-show="loading" class="text-center py-12">
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading dashboard...</p>
</div>
<!-- Error State -->
<div x-show="error && !loading" class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
<div>
<p class="font-semibold">Error loading dashboard</p>
<p class="text-sm" x-text="error"></p>
</div>
</div>
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card: Total Vendors -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('user-group', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Vendors
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.totalVendors">
0
</p>
</div>
</div>
<!-- Card: Active Users -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('users', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Active Users
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.activeUsers">
0
</p>
</div>
</div>
<!-- Card: Verified Vendors -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('badge-check', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Verified Vendors
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.verifiedVendors">
0
</p>
</div>
</div>
<!-- Card: Import Jobs -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-teal-500 bg-teal-100 rounded-full dark:text-teal-100 dark:bg-teal-500">
<span x-html="$icon('download', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Import Jobs
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.importJobs">
0
</p>
</div>
</div>
</div>
<!-- Recent Vendors Table -->
<div x-show="!loading" class="w-full overflow-hidden rounded-lg shadow-xs">
<div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap">
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Vendor</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Created</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="recentVendors.length === 0">
<tr>
<td colspan="4" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('user-group', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p>No vendors yet.</p>
</div>
</td>
</tr>
</template>
<template x-for="vendor in recentVendors" :key="vendor.vendor_code">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block">
<div class="absolute inset-0 rounded-full bg-purple-100 dark:bg-purple-600 flex items-center justify-center">
<span class="text-xs font-semibold text-purple-600 dark:text-purple-100" x-text="vendor.name?.charAt(0).toUpperCase() || '?'"></span>
</div>
</div>
<div>
<p class="font-semibold" x-text="vendor.name"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="vendor.vendor_code"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-xs">
<span class="inline-flex items-center px-2 py-1 font-semibold leading-tight rounded-full"
:class="vendor.is_verified ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-orange-700 bg-orange-100 dark:text-white dark:bg-orange-600'">
<span x-show="vendor.is_verified" x-html="$icon('badge-check', 'w-3 h-3 mr-1')"></span>
<span x-text="vendor.is_verified ? 'Verified' : 'Pending'"></span>
</span>
</td>
<td class="px-4 py-3 text-sm" x-text="formatDate(vendor.created_at)">
</td>
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<button
@click="viewVendor(vendor.vendor_code)"
class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-gray-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="View vendor"
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/dashboard.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,109 @@
{# app/templates/admin/login.html #}
<!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="adminLogin()" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin Login - Multi-Tenant Platform</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
<style>
[x-cloak] { display: none !important; }
</style>
</head>
<body>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
<div class="flex-1 h-full max-w-4xl mx-auto overflow-hidden bg-white rounded-lg shadow-xl dark:bg-gray-800">
<div class="flex flex-col overflow-y-auto md:flex-row">
<div class="h-32 md:h-auto md:w-1/2">
<img aria-hidden="true" class="object-cover w-full h-full dark:hidden"
src="{{ url_for('static', path='admin/img/login-office.jpeg') }}" alt="Office" />
<img aria-hidden="true" class="hidden object-cover w-full h-full dark:block"
src="{{ url_for('static', path='admin/img/login-office-dark.jpeg') }}" alt="Office" />
</div>
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
<div class="w-full">
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
Admin Login
</h1>
<!-- Alert Messages -->
<div x-show="error" x-text="error"
class="px-4 py-3 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800"
x-transition></div>
<div x-show="success" x-text="success"
class="px-4 py-3 mb-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800"
x-transition></div>
<!-- Login Form -->
<form @submit.prevent="handleLogin">
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Username</span>
<input x-model="credentials.username"
:disabled="loading"
@input="clearErrors"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
:class="{ 'border-red-600': errors.username }"
placeholder="Enter your username"
autocomplete="username"
required />
<span x-show="errors.username" x-text="errors.username"
class="text-xs text-red-600 dark:text-red-400"></span>
</label>
<label class="block mt-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">Password</span>
<input x-model="credentials.password"
:disabled="loading"
@input="clearErrors"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
:class="{ 'border-red-600': errors.password }"
placeholder="***************"
type="password"
autocomplete="current-password"
required />
<span x-show="errors.password" x-text="errors.password"
class="text-xs text-red-600 dark:text-red-400"></span>
</label>
<button type="submit" :disabled="loading"
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!loading">Log in</span>
<span x-show="loading">
<svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Signing in...
</span>
</button>
</form>
<hr class="my-8" />
<p class="mt-4">
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline"
href="#">
Forgot your password?
</a>
</p>
<p class="mt-2">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="/">
← Back to Platform
</a>
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Scripts -->
<script src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<script src="{{ url_for('static', path='admin/js/login.js') }}"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
</body>
</html>

View File

@@ -100,7 +100,8 @@
</a> </a>
</li> </li>
<li class="flex"> <li class="flex">
<a class="inline-flex items-center w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200" href="/static/admin/login.html"> <a class="inline-flex items-center w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200"
href="/admin/login"> <!-- ← Changed from /static/admin/login.html -->
<span x-html="$icon('logout', 'w-4 h-4 mr-3')"></span> <span x-html="$icon('logout', 'w-4 h-4 mr-3')"></span>
<span>Log out</span> <span>Log out</span>
</a> </a>
@@ -110,24 +111,4 @@
</li> </li>
</ul> </ul>
</div> </div>
</header>.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span>Settings</span>
</a>
</li>
<li class="flex">
<a class="inline-flex items-center w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200" href="/static/admin/login.html">
<!-- Heroicon: logout -->
<svg class="w-4 h-4 mr-3" aria-hidden="true" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" stroke="currentColor">
<path d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path>
</svg>
<span>Log out</span>
</a>
</li>
</ul>
</template>
</li>
</ul>
</div>
</header> </header>

View File

@@ -1,7 +1,8 @@
<!-- app/templates/partials/sidebar.html -->
<!-- Desktop sidebar --> <!-- Desktop sidebar -->
<aside class="z-20 hidden w-64 overflow-y-auto bg-white dark:bg-gray-800 md:block flex-shrink-0"> <aside class="z-20 hidden w-64 overflow-y-auto bg-white dark:bg-gray-800 md:block flex-shrink-0">
<div class="py-4 text-gray-500 dark:text-gray-400"> <div class="py-4 text-gray-500 dark:text-gray-400">
<a class="ml-6 text-lg font-bold text-gray-800 dark:text-gray-200" href="/static/admin/dashboard.html"> <a class="ml-6 text-lg font-bold text-gray-800 dark:text-gray-200" href="/admin/dashboard">
Admin Portal Admin Portal
</a> </a>
<ul class="mt-6"> <ul class="mt-6">
@@ -9,7 +10,7 @@
<span x-show="currentPage === 'dashboard'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span> <span x-show="currentPage === 'dashboard'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200" <a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'dashboard' ? 'text-gray-800 dark:text-gray-100' : ''" :class="currentPage === 'dashboard' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/static/admin/dashboard.html"> href="/admin/dashboard">
<span x-html="$icon('home')"></span> <span x-html="$icon('home')"></span>
<span class="ml-4">Dashboard</span> <span class="ml-4">Dashboard</span>
</a> </a>
@@ -20,19 +21,21 @@
<span x-show="currentPage === 'vendors'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span> <span x-show="currentPage === 'vendors'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200" <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' : ''" :class="currentPage === 'vendors' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/static/admin/vendors.html"> 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>
</a> </a>
</li> </li>
<li class="relative px-6 py-3"> <li class="relative px-6 py-3">
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200" href="#"> <a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
href="/admin/users">
<span x-html="$icon('users')"></span> <span x-html="$icon('users')"></span>
<span class="ml-4">Users</span> <span class="ml-4">Users</span>
</a> </a>
</li> </li>
<li class="relative px-6 py-3"> <li class="relative px-6 py-3">
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200" href="#"> <a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
href="#">
<span x-html="$icon('cube')"></span> <span x-html="$icon('cube')"></span>
<span class="ml-4">Import Jobs</span> <span class="ml-4">Import Jobs</span>
</a> </a>
@@ -48,7 +51,6 @@
</aside> </aside>
<!-- Mobile sidebar --> <!-- Mobile sidebar -->
<!-- Backdrop -->
<div x-show="isSideMenuOpen" <div x-show="isSideMenuOpen"
x-transition:enter="transition ease-in-out duration-150" x-transition:enter="transition ease-in-out duration-150"
x-transition:enter-start="opacity-0" x-transition:enter-start="opacity-0"
@@ -69,7 +71,7 @@
@click.away="closeSideMenu" @click.away="closeSideMenu"
@keydown.escape="closeSideMenu"> @keydown.escape="closeSideMenu">
<div class="py-4 text-gray-500 dark:text-gray-400"> <div class="py-4 text-gray-500 dark:text-gray-400">
<a class="ml-6 text-lg font-bold text-gray-800 dark:text-gray-200" href="/static/admin/dashboard.html"> <a class="ml-6 text-lg font-bold text-gray-800 dark:text-gray-200" href="/admin/dashboard">
Admin Portal Admin Portal
</a> </a>
<ul class="mt-6"> <ul class="mt-6">
@@ -77,7 +79,7 @@
<span x-show="currentPage === 'dashboard'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span> <span x-show="currentPage === 'dashboard'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200" <a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === 'dashboard' ? 'text-gray-800 dark:text-gray-100' : ''" :class="currentPage === 'dashboard' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/static/admin/dashboard.html"> href="/admin/dashboard">
<span x-html="$icon('home')"></span> <span x-html="$icon('home')"></span>
<span class="ml-4">Dashboard</span> <span class="ml-4">Dashboard</span>
</a> </a>
@@ -88,19 +90,21 @@
<span x-show="currentPage === 'vendors'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span> <span x-show="currentPage === 'vendors'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200" <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' : ''" :class="currentPage === 'vendors' ? 'text-gray-800 dark:text-gray-100' : ''"
href="/static/admin/vendors.html"> 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>
</a> </a>
</li> </li>
<li class="relative px-6 py-3"> <li class="relative px-6 py-3">
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200" href="#"> <a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
href="/admin/users">
<span x-html="$icon('users')"></span> <span x-html="$icon('users')"></span>
<span class="ml-4">Users</span> <span class="ml-4">Users</span>
</a> </a>
</li> </li>
<li class="relative px-6 py-3"> <li class="relative px-6 py-3">
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200" href="#"> <a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
href="#">
<span x-html="$icon('cube')"></span> <span x-html="$icon('cube')"></span>
<span class="ml-4">Import Jobs</span> <span class="ml-4">Import Jobs</span>
</a> </a>

View File

@@ -324,6 +324,7 @@ async def vendor_context_middleware(request: Request, call_next):
### Template Structure (Jinja2) ### Template Structure (Jinja2)
#### Base Template (`templates/base.html`) #### Base Template (`templates/base.html`)
```html ```html
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -331,20 +332,20 @@ async def vendor_context_middleware(request: Request, call_next):
<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 %}Multi-Tenant Platform{% endblock %}</title> <title>{% block title %}Multi-Tenant Platform{% endblock %}</title>
<!-- CSS --> <!-- CSS -->
<link rel="stylesheet" href="/static/css/shared/base.css"> <link rel="stylesheet" href="/static/css/shared/base.css">
{% block extra_css %}{% endblock %} {% block extra_css %}{% endblock %}
<!-- Alpine.js from CDN --> <!-- Alpine.js from CDN -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head> </head>
<body> <body>
{% block content %}{% endblock %} {% block content %}{% endblock %}
<!-- Shared JavaScript --> <!-- Shared JavaScript -->
<script src="/static/js/shared/api-client.js"></script> <script src="/static/shared/js/api-client.js"></script>
{% block extra_scripts %}{% endblock %} {% block extra_scripts %}{% endblock %}
</body> </body>
</html> </html>
``` ```

98
frontend-structure.txt Normal file
View File

@@ -0,0 +1,98 @@
Frontend Folder Structure
Generated: 18/10/2025 13:53:32.04
==============================================================================
Folder PATH listing for volume Data2
Volume serial number is 00000011 A008:CC27
E:\LETZSHOP-IMPORT\STATIC
+---admin
| dashboard.html
| login.html
| marketplace.html
| monitoring.html
| users.html
| vendor-edit.html
| vendors.html
|
+---css
| +---admin
| | admin.css
| |
| +---shared
| | auth.css
| | base.css
| | responsive-utilities.css
| |
| +---shop
| +---themes
| \---vendor
| vendor.css
|
+---js
| +---admin
| | analytics.js
| | dashboard.js
| | login.js
| | monitoring.js
| | vendor-edit.js
| | vendors.js
| |
| +---shared
| | api-client.js
| | media-upload.js
| | notification.js
| | search.js
| | vendor-context.js
| |
| +---shop
| | account.js
| | cart.js
| | catalog.js
| | checkout.js
| | search.js
| |
| \---vendor
| dashboard.js
| login.js
| marketplace.js
| media.js
| orders.js
| payments.js
| products.js
|
+---shop
| | cart.html
| | checkout.html
| | home.html
| | product.html
| | products.html
| | search.html
| |
| \---account
| addresses.html
| login.html
| orders.html
| profile.html
| register.html
|
\---vendor
| dashboard.html
| login.html
|
\---admin
| customers.html
| inventory.html
| media.html
| notifications.html
| orders.html
| payments.html
| products.html
| settings.html
| teams.html
|
\---marketplace
browse.html
config.html
imports.html
selected.html

27
main.py
View File

@@ -3,15 +3,17 @@ import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from fastapi import Depends, FastAPI, HTTPException from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.main import api_router from app.api.main import api_router
from app.routes.frontend import router as frontend_router from app.routes.frontend import router as frontend_router # We'll phase this out
from app.api.v1.admin import pages as admin_pages
from app.core.config import settings from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.core.lifespan import lifespan from app.core.lifespan import lifespan
@@ -24,6 +26,7 @@ logger = logging.getLogger(__name__)
# Get the project root directory (where main.py is located) # Get the project root directory (where main.py is located)
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static" STATIC_DIR = BASE_DIR / "static"
TEMPLATES_DIR = BASE_DIR / "app" / "templates"
# FastAPI app with lifespan # FastAPI app with lifespan
app = FastAPI( app = FastAPI(
@@ -33,6 +36,9 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
# Configure Jinja2 Templates
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
# Setup custom exception handlers (unified approach) # Setup custom exception handlers (unified approach)
setup_exception_handlers(app) setup_exception_handlers(app)
@@ -45,7 +51,7 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# Add vendor context middleware (ADDED - must be after CORS) # Add vendor context middleware (must be after CORS)
app.middleware("http")(vendor_context_middleware) app.middleware("http")(vendor_context_middleware)
# ======================================== # ========================================
@@ -57,8 +63,21 @@ else:
logger.warning(f"Static directory not found at {STATIC_DIR}") logger.warning(f"Static directory not found at {STATIC_DIR}")
# ======================================== # ========================================
# Include API router # Include API router (JSON endpoints at /api/*)
app.include_router(api_router, prefix="/api") app.include_router(api_router, prefix="/api")
# ============================================================================
# Include HTML page routes (Jinja2 templates at /admin/*)
# ============================================================================
app.include_router(
admin_pages.router,
prefix="/admin",
tags=["admin-pages"],
include_in_schema=False # Don't show HTML pages in API docs
)
# ============================================================================
# OLD: Keep frontend router for now (we'll phase it out)
app.include_router(frontend_router) app.include_router(frontend_router)
# Public Routes (no authentication required) # Public Routes (no authentication required)

View File

@@ -15,7 +15,7 @@ alembic==1.14.0
# Authentication and Security # Authentication and Security
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
bcrypt==4.2.1 bcrypt==4.0.1 # Changed from 4.2.1 for Python 3.13.5 compatibility
python-multipart==0.0.20 python-multipart==0.0.20
# Data processing # Data processing

0
scripts/init_db.py Normal file
View File

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="dashboardData()" lang="en"> <html :class="{ 'theme-dark': dark }" x-data="adminDashboard()" 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" />
@@ -12,28 +12,53 @@
</head> </head>
<body x-cloak> <body x-cloak>
<div class="flex h-screen bg-gray-50 dark:bg-gray-900" :class="{ 'overflow-hidden': isSideMenuOpen }"> <div class="flex h-screen bg-gray-50 dark:bg-gray-900" :class="{ 'overflow-hidden': isSideMenuOpen }">
<!-- Sidebar Container --> <!-- Sidebar Container (loaded via partial-loader) -->
<div id="sidebar-container"></div> <div id="sidebar-container"></div>
<div class="flex flex-col flex-1 w-full"> <div class="flex flex-col flex-1 w-full">
<!-- Header Container --> <!-- Header Container (loaded via partial-loader) -->
<div id="header-container"></div> <div id="header-container"></div>
<!-- Main Content --> <!-- Main Content -->
<main class="h-full overflow-y-auto"> <main class="h-full overflow-y-auto">
<div class="container px-6 mx-auto grid"> <div class="container px-6 mx-auto grid">
<h2 class="my-6 text-2xl font-semibold text-gray-700 dark:text-gray-200"> <!-- Page Header with Refresh Button -->
Dashboard <div class="flex items-center justify-between my-6">
</h2> <h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
Dashboard
</h2>
<button
@click="refresh()"
:disabled="loading"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
</button>
</div>
<!-- Loading State -->
<div x-show="loading" class="text-center py-12">
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading dashboard...</p>
</div>
<!-- Error State -->
<div x-show="error && !loading" class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
<div>
<p class="font-semibold">Error loading dashboard</p>
<p class="text-sm" x-text="error"></p>
</div>
</div>
<!-- Stats Cards --> <!-- Stats Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4"> <div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card: Total Vendors --> <!-- Card: Total Vendors -->
<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-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <span x-html="$icon('user-group', 'w-5 h-5')"></span>
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"></path>
</svg>
</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">
@@ -48,9 +73,7 @@
<!-- Card: Active Users --> <!-- Card: Active Users -->
<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-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500"> <div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <span x-html="$icon('users', 'w-5 h-5')"></span>
<path fill-rule="evenodd" d="M4 4a2 2 0 00-2 2v4a2 2 0 002 2V6h10a2 2 0 00-2-2H4zm2 6a2 2 0 012-2h8a2 2 0 012 2v4a2 2 0 01-2 2H8a2 2 0 01-2-2v-4zm6 4a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"></path>
</svg>
</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">
@@ -65,9 +88,7 @@
<!-- Card: Verified Vendors --> <!-- Card: Verified Vendors -->
<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-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500"> <div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <span x-html="$icon('badge-check', 'w-5 h-5')"></span>
<path d="M3 1a1 1 0 000 2h1.22l.305 1.222a.997.997 0 00.01.042l1.358 5.43-.893.892C3.74 11.846 4.632 14 6.414 14H15a1 1 0 000-2H6.414l1-1H14a1 1 0 00.894-.553l3-6A1 1 0 0017 3H6.28l-.31-1.243A1 1 0 005 1H3zM16 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM6.5 18a1.5 1.5 0 100-3 1.5 1.5 0 000 3z"></path>
</svg>
</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">
@@ -82,9 +103,7 @@
<!-- Card: Import Jobs --> <!-- Card: Import Jobs -->
<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-teal-500 bg-teal-100 rounded-full dark:text-teal-100 dark:bg-teal-500"> <div class="p-3 mr-4 text-teal-500 bg-teal-100 rounded-full dark:text-teal-100 dark:bg-teal-500">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> <span x-html="$icon('download', 'w-5 h-5')"></span>
<path fill-rule="evenodd" d="M18 5v8a2 2 0 01-2 2h-5l-5 4v-4H4a2 2 0 01-2-2V5a2 2 0 012-2h12a2 2 0 012 2zM7 8H5v2h2V8zm2 0h2v2H9V8zm6 0h-2v2h2V8z" clip-rule="evenodd"></path>
</svg>
</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">
@@ -98,7 +117,7 @@
</div> </div>
<!-- Recent Vendors Table --> <!-- Recent Vendors Table -->
<div class="w-full overflow-hidden rounded-lg shadow-xs"> <div x-show="!loading" class="w-full overflow-hidden rounded-lg shadow-xs">
<div class="w-full overflow-x-auto"> <div class="w-full overflow-x-auto">
<table class="w-full whitespace-no-wrap"> <table class="w-full whitespace-no-wrap">
<thead> <thead>
@@ -110,21 +129,24 @@
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800"> <tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="vendors.length === 0"> <template x-if="recentVendors.length === 0">
<tr> <tr>
<td colspan="4" class="px-4 py-3 text-sm text-center text-gray-600 dark:text-gray-400"> <td colspan="4" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
No vendors yet. <a href="/static/admin/vendors.html" class="text-purple-600 hover:underline">Create your first vendor</a> <div class="flex flex-col items-center">
<span x-html="$icon('user-group', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p>No vendors yet.</p>
</div>
</td> </td>
</tr> </tr>
</template> </template>
<template x-for="vendor in vendors" :key="vendor.id"> <template x-for="vendor in recentVendors" :key="vendor.vendor_code">
<tr class="text-gray-700 dark:text-gray-400"> <tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center text-sm"> <div class="flex items-center text-sm">
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block"> <div class="relative hidden w-8 h-8 mr-3 rounded-full md:block">
<div class="absolute inset-0 rounded-full bg-purple-100 dark:bg-purple-600 flex items-center justify-center"> <div class="absolute inset-0 rounded-full bg-purple-100 dark:bg-purple-600 flex items-center justify-center">
<span class="text-xs font-semibold text-purple-600 dark:text-purple-100" x-text="vendor.name.charAt(0)"></span> <span class="text-xs font-semibold text-purple-600 dark:text-purple-100" x-text="vendor.name?.charAt(0).toUpperCase() || '?'"></span>
</div> </div>
</div> </div>
<div> <div>
@@ -134,20 +156,22 @@
</div> </div>
</td> </td>
<td class="px-4 py-3 text-xs"> <td class="px-4 py-3 text-xs">
<span class="px-2 py-1 font-semibold leading-tight rounded-full" <span class="inline-flex items-center px-2 py-1 font-semibold leading-tight rounded-full"
:class="vendor.is_verified ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-orange-700 bg-orange-100 dark:text-white dark:bg-orange-600'" :class="vendor.is_verified ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-orange-700 bg-orange-100 dark:text-white dark:bg-orange-600'">
x-text="vendor.is_verified ? 'Verified' : 'Pending'"> <span x-show="vendor.is_verified" x-html="$icon('badge-check', 'w-3 h-3 mr-1')"></span>
<span x-text="vendor.is_verified ? 'Verified' : 'Pending'"></span>
</span> </span>
</td> </td>
<td class="px-4 py-3 text-sm" x-text="formatDate(vendor.created_at)"> <td class="px-4 py-3 text-sm" x-text="formatDate(vendor.created_at)">
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center space-x-4 text-sm"> <div class="flex items-center space-x-2 text-sm">
<button class="flex items-center justify-between px-2 py-2 text-sm font-medium leading-5 text-purple-600 rounded-lg dark:text-gray-400 focus:outline-none focus:shadow-outline-gray" <button
aria-label="Edit"> @click="viewVendor(vendor.vendor_code)"
<svg class="w-5 h-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20"> class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-gray-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"></path> title="View vendor"
</svg> >
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</button> </button>
</div> </div>
</td> </td>
@@ -162,11 +186,15 @@
</div> </div>
</div> </div>
<!-- Load partials BEFORE Alpine --> <!-- Scripts in CORRECT ORDER -->
<!-- 1. Partial Loader (auto-detects admin area) -->
<script src="/static/shared/js/partial-loader.js"></script> <script src="/static/shared/js/partial-loader.js"></script>
<!-- 2. Icons Helper -->
<script src="/static/shared/js/icons.js"></script> <script src="/static/shared/js/icons.js"></script>
<!-- 3. Load Partials (before Alpine initializes) -->
<script> <script>
// Load partials synchronously before Alpine starts
(async () => { (async () => {
await window.partialLoader.loadAll({ await window.partialLoader.loadAll({
'header-container': 'header.html', 'header-container': 'header.html',
@@ -175,84 +203,17 @@
})(); })();
</script> </script>
<!-- Alpine.js v3 --> <!-- 4. Base Alpine Data -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
<!-- Initialize Alpine data -->
<script src="/static/admin/js/init-alpine.js"></script> <script src="/static/admin/js/init-alpine.js"></script>
<!-- Dashboard-specific logic --> <!-- 5. API Client & Utils -->
<script> <script src="/static/shared/js/api-client.js"></script>
function dashboardData() { <script src="/static/shared/js/utils.js"></script>
return {
...data(), // Spread base data from init-alpine.js
currentPage: 'dashboard',
stats: {
totalVendors: 0,
activeUsers: 0,
verifiedVendors: 0,
importJobs: 0
},
vendors: [],
loading: false,
async init() { <!-- 6. Alpine.js v3 (deferred) -->
await this.loadStats(); <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
await this.loadVendors();
},
async loadStats() { <!-- 7. Dashboard-specific script (AFTER Alpine loads) -->
try { <script src="/static/admin/js/dashboard.js"></script>
// Replace with your actual API endpoint
const response = await fetch('/api/v1/admin/dashboard/stats', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
const data = await response.json();
this.stats = {
totalVendors: data.vendors?.total_vendors || 0,
activeUsers: data.users?.active_users || 0,
verifiedVendors: data.vendors?.verified_vendors || 0,
importJobs: data.imports?.total_imports || 0
};
}
} catch (error) {
console.error('Error loading stats:', error);
}
},
async loadVendors() {
try {
// Replace with your actual API endpoint
const response = await fetch('/api/v1/admin/vendors?limit=5', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
const data = await response.json();
this.vendors = data.vendors || [];
}
} catch (error) {
console.error('Error loading vendors:', error);
}
},
formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
}
}
</script>
</body> </body>
</html> </html>

View File

@@ -1,72 +1,126 @@
/** // static/admin/js/dashboard.js
* Admin Dashboard Component
* Extends adminLayout with dashboard-specific functionality // Log levels: 0 = None, 1 = Error, 2 = Warning, 3 = Info, 4 = Debug
*/ const DASHBOARD_LOG_LEVEL = 3; // Set to 3 for production, 4 for full debugging
const dashLog = {
error: (...args) => DASHBOARD_LOG_LEVEL >= 1 && console.error('❌ [DASHBOARD ERROR]', ...args),
warn: (...args) => DASHBOARD_LOG_LEVEL >= 2 && console.warn('⚠️ [DASHBOARD WARN]', ...args),
info: (...args) => DASHBOARD_LOG_LEVEL >= 3 && console.info(' [DASHBOARD INFO]', ...args),
debug: (...args) => DASHBOARD_LOG_LEVEL >= 4 && console.log('🔍 [DASHBOARD DEBUG]', ...args)
};
function adminDashboard() { function adminDashboard() {
return { return {
// Inherit all adminLayout functionality // Inherit base layout functionality from init-alpine.js
...window.adminLayout(), ...data(),
// Dashboard-specific state // Dashboard-specific state
currentSection: 'dashboard', currentPage: 'dashboard',
stats: { stats: {
vendors: {}, totalVendors: 0,
users: {}, activeUsers: 0,
imports: {} verifiedVendors: 0,
importJobs: 0
}, },
vendors: [],
users: [],
imports: [],
recentVendors: [], recentVendors: [],
recentImports: [], loading: true,
loading: false, error: null,
/** /**
* Initialize dashboard * Initialize dashboard
*/ */
async init() { async init() {
// Call parent init from adminLayout dashLog.info('=== DASHBOARD INITIALIZING ===');
this.currentPage = this.getCurrentPage(); dashLog.debug('Current URL:', window.location.href);
await this.loadUserData(); dashLog.debug('Current pathname:', window.location.pathname);
// Load dashboard data const token = localStorage.getItem('admin_token');
await this.loadDashboardData(); dashLog.debug('Has admin_token?', !!token);
if (token) {
dashLog.debug('Token preview:', token.substring(0, 20) + '...');
}
// Prevent multiple initializations
if (window._dashboardInitialized) {
dashLog.warn('Dashboard already initialized, skipping...');
return;
}
window._dashboardInitialized = true;
dashLog.debug('Dashboard initialization flag set');
await this.loadDashboard();
dashLog.info('=== DASHBOARD INITIALIZATION COMPLETE ===');
}, },
/** /**
* Load all dashboard data * Load all dashboard data
*/ */
async loadDashboardData() { async loadDashboard() {
dashLog.info('Loading dashboard data...');
this.loading = true; this.loading = true;
this.error = null;
dashLog.debug('Dashboard state: loading=true, error=null');
try { try {
dashLog.info('Starting parallel data fetch...');
const startTime = Date.now();
// Load stats and vendors in parallel
await Promise.all([ await Promise.all([
this.loadStats(), this.loadStats(),
this.loadRecentVendors(), this.loadRecentVendors()
this.loadRecentImports()
]); ]);
const duration = Date.now() - startTime;
dashLog.info(`Dashboard data loaded successfully in ${duration}ms`);
} catch (error) { } catch (error) {
console.error('Error loading dashboard data:', error); dashLog.error('Dashboard load error:', error);
this.showErrorModal({ dashLog.error('Error details:', {
message: 'Failed to load dashboard data', message: error.message,
details: error.message name: error.name,
stack: error.stack
}); });
this.error = error.message;
Utils.showToast('Failed to load dashboard data', 'error');
} finally { } finally {
this.loading = false; this.loading = false;
dashLog.debug('Dashboard state: loading=false');
dashLog.info('Dashboard load attempt finished');
} }
}, },
/** /**
* Load statistics * Load platform statistics
*/ */
async loadStats() { async loadStats() {
dashLog.info('Loading platform statistics...');
dashLog.debug('API endpoint: /admin/dashboard/stats/platform');
try { try {
const response = await apiClient.get('/admin/stats'); const startTime = Date.now();
this.stats = response; const data = await apiClient.get('/admin/dashboard/stats/platform');
const duration = Date.now() - startTime;
dashLog.info(`Stats loaded in ${duration}ms`);
dashLog.debug('Raw stats data:', data);
// Map API response to stats cards
this.stats = {
totalVendors: data.vendors?.total_vendors || 0,
activeUsers: data.users?.active_users || 0,
verifiedVendors: data.vendors?.verified_vendors || 0,
importJobs: data.imports?.total_imports || 0
};
dashLog.info('Stats mapped:', this.stats);
} catch (error) { } catch (error) {
console.error('Failed to load stats:', error); dashLog.error('Failed to load stats:', error);
// Don't show error modal for stats, just log it throw error;
} }
}, },
@@ -74,111 +128,32 @@ function adminDashboard() {
* Load recent vendors * Load recent vendors
*/ */
async loadRecentVendors() { async loadRecentVendors() {
dashLog.info('Loading recent vendors...');
dashLog.debug('API endpoint: /admin/dashboard');
try { try {
const response = await apiClient.get('/admin/vendors', { const startTime = Date.now();
skip: 0, const data = await apiClient.get('/admin/dashboard');
limit: 5 const duration = Date.now() - startTime;
dashLog.info(`Recent vendors loaded in ${duration}ms`);
dashLog.debug('Vendors data:', {
count: data.recent_vendors?.length || 0,
hasData: !!data.recent_vendors
}); });
this.recentVendors = response.vendors || response;
this.recentVendors = data.recent_vendors || [];
if (this.recentVendors.length > 0) {
dashLog.info(`Loaded ${this.recentVendors.length} recent vendors`);
dashLog.debug('First vendor:', this.recentVendors[0]);
} else {
dashLog.warn('No recent vendors found');
}
} catch (error) { } catch (error) {
console.error('Failed to load recent vendors:', error); dashLog.error('Failed to load recent vendors:', error);
} throw error;
},
/**
* Load recent import jobs
*/
async loadRecentImports() {
try {
const response = await apiClient.get('/admin/imports', {
skip: 0,
limit: 5
});
this.recentImports = response.imports || response;
} catch (error) {
console.error('Failed to load recent imports:', error);
}
},
/**
* Show different sections
*/
async showSection(section) {
this.currentSection = section;
// Load data based on section
if (section === 'vendors' && this.vendors.length === 0) {
await this.loadAllVendors();
} else if (section === 'users' && this.users.length === 0) {
await this.loadAllUsers();
} else if (section === 'imports' && this.imports.length === 0) {
await this.loadAllImports();
}
},
/**
* Load all vendors
*/
async loadAllVendors() {
this.loading = true;
try {
const response = await apiClient.get('/admin/vendors', {
skip: 0,
limit: 100
});
this.vendors = response.vendors || response;
} catch (error) {
console.error('Failed to load vendors:', error);
this.showErrorModal({
message: 'Failed to load vendors',
details: error.message
});
} finally {
this.loading = false;
}
},
/**
* Load all users
*/
async loadAllUsers() {
this.loading = true;
try {
const response = await apiClient.get('/admin/users', {
skip: 0,
limit: 100
});
this.users = response.users || response;
} catch (error) {
console.error('Failed to load users:', error);
this.showErrorModal({
message: 'Failed to load users',
details: error.message
});
} finally {
this.loading = false;
}
},
/**
* Load all import jobs
*/
async loadAllImports() {
this.loading = true;
try {
const response = await apiClient.get('/admin/imports', {
skip: 0,
limit: 100
});
this.imports = response.imports || response;
} catch (error) {
console.error('Failed to load import jobs:', error);
this.showErrorModal({
message: 'Failed to load import jobs',
details: error.message
});
} finally {
this.loading = false;
} }
}, },
@@ -186,18 +161,35 @@ function adminDashboard() {
* Format date for display * Format date for display
*/ */
formatDate(dateString) { formatDate(dateString) {
if (!dateString) return '-'; if (!dateString) {
dashLog.debug('formatDate called with empty dateString');
try { return '-';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch (error) {
return dateString;
} }
const formatted = Utils.formatDate(dateString);
dashLog.debug(`Date formatted: ${dateString} -> ${formatted}`);
return formatted;
},
/**
* Navigate to vendor detail page
*/
viewVendor(vendorCode) {
dashLog.info('Navigating to vendor:', vendorCode);
const url = `/admin/vendors?code=${vendorCode}`;
dashLog.debug('Navigation URL:', url);
window.location.href = url;
},
/**
* Refresh dashboard data
*/
async refresh() {
dashLog.info('=== DASHBOARD REFRESH TRIGGERED ===');
await this.loadDashboard();
Utils.showToast('Dashboard refreshed', 'success');
dashLog.info('=== DASHBOARD REFRESH COMPLETE ===');
} }
}; };
} }
dashLog.info('Dashboard module loaded');

View File

@@ -1,7 +1,18 @@
// Admin Login Component // static/admin/js/login.js
// Log levels: 0 = None, 1 = Error, 2 = Warning, 3 = Info, 4 = Debug
const LOG_LEVEL = 4; // Set to 4 for full debugging, 1 for errors only
const log = {
error: (...args) => LOG_LEVEL >= 1 && console.error('❌ [ERROR]', ...args),
warn: (...args) => LOG_LEVEL >= 2 && console.warn('⚠️ [WARN]', ...args),
info: (...args) => LOG_LEVEL >= 3 && console.info(' [INFO]', ...args),
debug: (...args) => LOG_LEVEL >= 4 && console.log('🔍 [DEBUG]', ...args)
};
function adminLogin() { function adminLogin() {
return { return {
dark: false, // For dark mode toggle dark: false,
credentials: { credentials: {
username: '', username: '',
password: '' password: ''
@@ -12,119 +23,192 @@ function adminLogin() {
errors: {}, errors: {},
init() { init() {
// Check if already logged in log.info('Login page initializing...');
this.checkExistingAuth(); log.debug('Current pathname:', window.location.pathname);
log.debug('Current URL:', window.location.href);
// Check for dark mode preference // Just set theme - NO auth checking, NO token clearing!
this.dark = localStorage.getItem('theme') === 'dark'; this.dark = localStorage.getItem('theme') === 'dark';
}, log.debug('Dark mode:', this.dark);
checkExistingAuth() { // DON'T clear tokens on init!
const token = localStorage.getItem('admin_token') || localStorage.getItem('token'); // If user lands here with a valid token, they might be navigating manually
// or got redirected. Let them try to login or navigate away.
const token = localStorage.getItem('admin_token');
if (token) { if (token) {
// Verify token is still valid log.warn('Found existing token on login page');
const userData = localStorage.getItem('admin_user'); log.debug('Token preview:', token.substring(0, 20) + '...');
if (userData) { log.info('Not clearing token - user may have navigated here manually');
try { } else {
const user = JSON.parse(userData); log.debug('No existing token found');
if (user.role === 'admin') {
window.location.href = '/static/admin/dashboard.html';
}
} catch (e) {
// Invalid user data, clear storage
this.clearAuthData();
}
}
} }
log.info('Login page initialization complete');
}, },
clearAuthData() { clearTokens() {
log.debug('Clearing all auth tokens...');
const tokensBefore = {
admin_token: !!localStorage.getItem('admin_token'),
admin_user: !!localStorage.getItem('admin_user'),
token: !!localStorage.getItem('token')
};
log.debug('Tokens before clear:', tokensBefore);
localStorage.removeItem('admin_token'); localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user'); localStorage.removeItem('admin_user');
localStorage.removeItem('token'); localStorage.removeItem('token');
const tokensAfter = {
admin_token: !!localStorage.getItem('admin_token'),
admin_user: !!localStorage.getItem('admin_user'),
token: !!localStorage.getItem('token')
};
log.debug('Tokens after clear:', tokensAfter);
}, },
clearErrors() { clearErrors() {
log.debug('Clearing form errors');
this.error = null; this.error = null;
this.success = null; this.success = null;
this.errors = {}; this.errors = {};
}, },
validateForm() { validateForm() {
log.debug('Validating login form...');
this.clearErrors(); this.clearErrors();
let isValid = true; let isValid = true;
if (!this.credentials.username.trim()) { if (!this.credentials.username.trim()) {
this.errors.username = 'Username is required'; this.errors.username = 'Username is required';
log.warn('Validation failed: Username is required');
isValid = false; isValid = false;
} }
if (!this.credentials.password) { if (!this.credentials.password) {
this.errors.password = 'Password is required'; this.errors.password = 'Password is required';
log.warn('Validation failed: Password is required');
isValid = false; isValid = false;
} else if (this.credentials.password.length < 6) { } else if (this.credentials.password.length < 6) {
this.errors.password = 'Password must be at least 6 characters'; this.errors.password = 'Password must be at least 6 characters';
log.warn('Validation failed: Password too short');
isValid = false; isValid = false;
} }
log.info('Form validation result:', isValid ? 'VALID' : 'INVALID');
return isValid; return isValid;
}, },
async handleLogin() { async handleLogin() {
log.info('=== LOGIN ATTEMPT STARTED ===');
if (!this.validateForm()) { if (!this.validateForm()) {
log.warn('Form validation failed, aborting login');
return; return;
} }
this.loading = true; this.loading = true;
this.clearErrors(); this.clearErrors();
log.debug('Login state set to loading');
try { try {
// Use apiClient from api-client.js log.info('Calling login API endpoint...');
log.debug('Username:', this.credentials.username);
log.debug('API endpoint: /api/v1/admin/auth/login');
const startTime = Date.now();
const response = await apiClient.post('/admin/auth/login', { const response = await apiClient.post('/admin/auth/login', {
username: this.credentials.username.trim(), username: this.credentials.username.trim(),
password: this.credentials.password password: this.credentials.password
}); });
const duration = Date.now() - startTime;
log.info(`Login API response received in ${duration}ms`);
log.debug('Response structure:', {
hasToken: !!response.access_token,
hasUser: !!response.user,
userRole: response.user?.role,
userName: response.user?.username
});
// Validate response // Validate response
if (!response.access_token) { if (!response.access_token) {
throw new Error('Invalid response from server'); log.error('Invalid response: No access token');
throw new Error('Invalid response from server - no token');
} }
// Check if user is admin (if user data is provided)
if (response.user && response.user.role !== 'admin') { if (response.user && response.user.role !== 'admin') {
log.error('Authorization failed: User is not admin', {
actualRole: response.user.role
});
throw new Error('Access denied. Admin privileges required.'); throw new Error('Access denied. Admin privileges required.');
} }
log.info('Login successful, storing authentication data...');
// Store authentication data // Store authentication data
localStorage.setItem('admin_token', response.access_token); localStorage.setItem('admin_token', response.access_token);
localStorage.setItem('token', response.access_token); // Backup localStorage.setItem('token', response.access_token);
log.debug('Token stored, length:', response.access_token.length);
if (response.user) { if (response.user) {
localStorage.setItem('admin_user', JSON.stringify(response.user)); localStorage.setItem('admin_user', JSON.stringify(response.user));
log.debug('User data stored:', {
username: response.user.username,
role: response.user.role,
id: response.user.id
});
} }
// Verify storage
const storedToken = localStorage.getItem('admin_token');
const storedUser = localStorage.getItem('admin_user');
log.info('Storage verification:', {
tokenStored: !!storedToken,
userStored: !!storedUser,
tokenLength: storedToken?.length
});
// Show success message // Show success message
this.success = 'Login successful! Redirecting...'; this.success = 'Login successful! Redirecting...';
log.info('Success message displayed to user');
// Redirect after short delay log.info('Redirecting to dashboard immediately...');
setTimeout(() => { log.info('=== EXECUTING REDIRECT ===');
window.location.href = '/static/admin/dashboard.html'; log.debug('Target URL: /admin/dashboard');
}, 1000); log.debug('Redirect method: window.location.href');
// Use href instead of replace to allow back button
// But redirect IMMEDIATELY - don't wait!
window.location.href = '/admin/dashboard';
} catch (error) { } catch (error) {
console.error('Login error:', error); log.error('Login failed:', error);
this.error = error.message || 'Invalid username or password. Please try again.'; log.error('Error details:', {
message: error.message,
name: error.name,
stack: error.stack
});
this.error = error.message || 'Invalid username or password. Please try again.';
log.info('Error message displayed to user:', this.error);
// Only clear tokens on login FAILURE
this.clearTokens();
log.info('Tokens cleared after error');
// Clear any partial auth data
this.clearAuthData();
} finally { } finally {
this.loading = false; this.loading = false;
log.debug('Login state set to not loading');
log.info('=== LOGIN ATTEMPT FINISHED ===');
} }
}, },
toggleDarkMode() { toggleDarkMode() {
log.debug('Toggling dark mode...');
this.dark = !this.dark; this.dark = !this.dark;
localStorage.setItem('theme', this.dark ? 'dark' : 'light'); localStorage.setItem('theme', this.dark ? 'dark' : 'light');
log.info('Dark mode:', this.dark ? 'ON' : 'OFF');
} }
} }
} }

View File

@@ -46,7 +46,7 @@ function vendorEdit() {
// Check authentication // Check authentication
if (!Auth.isAuthenticated() || !Auth.isAdmin()) { if (!Auth.isAuthenticated() || !Auth.isAdmin()) {
console.log('Not authenticated as admin, redirecting to login'); console.log('Not authenticated as admin, redirecting to login');
window.location.href = '/static/admin/login.html'; window.location.href = '/admin/login';
return; return;
} }
@@ -60,7 +60,7 @@ function vendorEdit() {
if (!this.vendorId) { if (!this.vendorId) {
console.error('No vendor ID in URL'); console.error('No vendor ID in URL');
alert('No vendor ID provided'); alert('No vendor ID provided');
window.location.href = '/static/admin/dashboard.html#vendors'; window.location.href = '/admin/dashboard.html#vendors';
return; return;
} }
@@ -95,7 +95,7 @@ function vendorEdit() {
} catch (error) { } catch (error) {
console.error('❌ Failed to load vendor:', error); console.error('❌ Failed to load vendor:', error);
Utils.showToast('Failed to load vendor details: ' + (error.message || 'Unknown error'), 'error'); Utils.showToast('Failed to load vendor details: ' + (error.message || 'Unknown error'), 'error');
window.location.href = '/static/admin/dashboard.html#vendors'; window.location.href = '/admin/dashboard';
} finally { } finally {
this.loadingVendor = false; this.loadingVendor = false;
} }
@@ -331,7 +331,7 @@ function vendorEdit() {
// Redirect to login after brief delay // Redirect to login after brief delay
setTimeout(() => { setTimeout(() => {
window.location.href = '/static/admin/login.html'; window.location.href = '/admin/login';
}, 500); }, 500);
}, },
}; };

View File

@@ -1,3 +1,4 @@
// static/admin/js/vendors.js
// Admin Vendor Creation Component // Admin Vendor Creation Component
function vendorCreation() { function vendorCreation() {
return { return {
@@ -26,7 +27,8 @@ function vendorCreation() {
checkAuth() { checkAuth() {
if (!Auth.isAuthenticated()) { if (!Auth.isAuthenticated()) {
window.location.href = '/static/admin/login.html'; // ← CHANGED: Use new Jinja2 route
window.location.href = '/admin/login';
return false; return false;
} }
@@ -34,7 +36,8 @@ function vendorCreation() {
if (!user || user.role !== 'admin') { if (!user || user.role !== 'admin') {
Utils.showToast('Access denied. Admin privileges required.', 'error'); Utils.showToast('Access denied. Admin privileges required.', 'error');
Auth.logout(); Auth.logout();
window.location.href = '/static/admin/login.html'; // ← CHANGED: Use new Jinja2 route
window.location.href = '/admin/login';
return false; return false;
} }
@@ -52,11 +55,14 @@ function vendorCreation() {
Auth.logout(); Auth.logout();
Utils.showToast('Logged out successfully', 'success', 2000); Utils.showToast('Logged out successfully', 'success', 2000);
setTimeout(() => { setTimeout(() => {
window.location.href = '/static/admin/login.html'; // ← CHANGED: Use new Jinja2 route
window.location.href = '/admin/login';
}, 500); }, 500);
} }
}, },
// ... rest of the methods stay the same ...
// Auto-format vendor code (uppercase) // Auto-format vendor code (uppercase)
formatVendorCode() { formatVendorCode() {
this.formData.vendor_code = this.formData.vendor_code this.formData.vendor_code = this.formData.vendor_code

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" x-data="loginData()" lang="en"> <html :class="{ 'theme-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" />
@@ -45,6 +45,7 @@
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input" class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
:class="{ 'border-red-600': errors.username }" :class="{ 'border-red-600': errors.username }"
placeholder="Enter your username" placeholder="Enter your username"
autocomplete="username"
required /> required />
<span x-show="errors.username" x-text="errors.username" <span x-show="errors.username" x-text="errors.username"
class="text-xs text-red-600 dark:text-red-400"></span> class="text-xs text-red-600 dark:text-red-400"></span>
@@ -59,6 +60,7 @@
:class="{ 'border-red-600': errors.password }" :class="{ 'border-red-600': errors.password }"
placeholder="***************" placeholder="***************"
type="password" type="password"
autocomplete="current-password"
required /> required />
<span x-show="errors.password" x-text="errors.password" <span x-show="errors.password" x-text="errors.password"
class="text-xs text-red-600 dark:text-red-400"></span> class="text-xs text-red-600 dark:text-red-400"></span>
@@ -85,80 +87,29 @@
Forgot your password? Forgot your password?
</a> </a>
</p> </p>
<p class="mt-2">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="/">
← Back to Platform
</a>
</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Alpine.js v3 --> <!-- Scripts in CORRECT ORDER -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script> <!-- 1. Icons FIRST (defines $icon magic) -->
<script src="/static/shared/js/icons.js"></script>
<!-- API Client --> <!-- 2. API Client -->
<script src="/static/shared/js/api-client.js"></script> <script src="/static/shared/js/api-client.js"></script>
<!-- Login Logic --> <!-- 3. Login Logic -->
<script> <script src="/static/admin/js/login.js"></script>
function loginData() {
return {
dark: false,
loading: false,
error: '',
success: '',
credentials: {
username: '',
password: ''
},
errors: {
username: '',
password: ''
},
clearErrors() { <!-- 4. Alpine.js LAST with defer -->
this.error = ''; <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
this.errors = { username: '', password: '' };
},
async handleLogin() {
this.clearErrors();
this.loading = true;
try {
// Your existing API call
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(this.credentials)
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Login failed');
}
// Store token
localStorage.setItem('token', data.access_token);
// Show success
this.success = 'Login successful! Redirecting...';
// Redirect to dashboard
setTimeout(() => {
window.location.href = '/static/admin/dashboard.html';
}, 1000);
} catch (error) {
this.error = error.message || 'Invalid username or password';
console.error('Login error:', error);
} finally {
this.loading = false;
}
}
}
}
</script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login - Multi-Tenant Ecommerce Platform</title>
<link rel="stylesheet" href="/static/css/shared/base.css">
<link rel="stylesheet" href="/static/css/shared/auth.css">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
<div class="auth-page" x-data="adminLogin()" x-cloak>
<div class="login-container">
<div class="login-header">
<div class="auth-logo">🔐</div>
<h1>Admin Portal</h1>
<p>Multi-Tenant Ecommerce Platform</p>
</div>
<!-- Alert Messages -->
<div x-show="error"
x-text="error"
class="alert alert-error"
x-transition></div>
<div x-show="success"
x-text="success"
class="alert alert-success"
x-transition></div>
<!-- Login Form -->
<form @submit.prevent="handleLogin">
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
x-model="credentials.username"
:class="{ 'error': errors.username }"
required
autocomplete="username"
placeholder="Enter your username"
:disabled="loading"
@input="clearErrors"
>
<div x-show="errors.username"
x-text="errors.username"
class="error-message show"
x-transition></div>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
x-model="credentials.password"
:class="{ 'error': errors.password }"
required
autocomplete="current-password"
placeholder="Enter your password"
:disabled="loading"
@input="clearErrors"
>
<div x-show="errors.password"
x-text="errors.password"
class="error-message show"
x-transition></div>
</div>
<button type="submit"
class="btn-login"
:disabled="loading">
<template x-if="!loading">
<span>Sign In</span>
</template>
<template x-if="loading">
<span>
<span class="loading-spinner"></span>
Signing in...
</span>
</template>
</button>
</form>
<div class="login-footer">
<a href="/">← Back to Platform</a>
</div>
</div>
</div>
<script src="/static/js/shared/api-client.js"></script>
<script src="/static/admin/js/login.js"></script>
</body>
</html>

View File

@@ -0,0 +1,72 @@
<!-- static/admin/partials/base-layout.html -->
<!DOCTYPE html>
<html :class="{ 'theme-dark': dark }" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-page-title>Admin Panel - Multi-Tenant Platform</title>
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<!-- Tailwind CSS -->
<link rel="stylesheet" href="/static/admin/css/tailwind.output.css" />
<!-- Alpine Cloak -->
<style>
[x-cloak] { display: none !important; }
</style>
<!-- Page-specific styles slot -->
<slot name="head"></slot>
</head>
<body x-cloak>
<div class="flex h-screen bg-gray-50 dark:bg-gray-900" :class="{ 'overflow-hidden': isSideMenuOpen }">
<!-- Sidebar Container -->
<div id="sidebar-container"></div>
<div class="flex flex-col flex-1 w-full">
<!-- Header Container -->
<div id="header-container"></div>
<!-- Main Content Area (Child pages inject content here) -->
<main class="h-full overflow-y-auto">
<div class="container px-6 mx-auto grid">
<!-- Page content slot -->
<slot name="content"></slot>
</div>
</main>
</div>
</div>
<!-- Core Scripts (loaded for all pages) -->
<!-- 1. Partial Loader -->
<script src="/static/shared/js/partial-loader.js"></script>
<!-- 2. Icons Helper -->
<script src="/static/shared/js/icons.js"></script>
<!-- 3. Load Header & Sidebar -->
<script>
(async () => {
await window.partialLoader.loadAll({
'header-container': 'header.html',
'sidebar-container': 'sidebar.html'
});
})();
</script>
<!-- 4. Base Alpine Data -->
<script src="/static/admin/js/init-alpine.js"></script>
<!-- 5. API Client & Utils -->
<script src="/static/shared/js/api-client.js"></script>
<script src="/static/shared/js/utils.js"></script>
<!-- 6. Alpine.js v3 -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
<!-- Page-specific scripts slot -->
<slot name="scripts"></slot>
</body>
</html>

View File

@@ -0,0 +1,644 @@
<!DOCTYPE html>
<html lang="en">
<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 {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
padding: 20px;
background: #f5f5f5;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 {
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>
<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>
<script>
// Update status display
function updateStatus() {
const token = localStorage.getItem('admin_token');
const userStr = localStorage.getItem('admin_user');
let user = null;
try {
user = userStr ? JSON.parse(userStr) : null;
} catch (e) {
console.error('Failed to parse user data:', e);
}
document.getElementById('currentUrl').textContent = window.location.href;
const hasTokenEl = document.getElementById('hasToken');
hasTokenEl.textContent = token ? 'Yes' : 'No';
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();
console.log('✅ All data cleared');
alert('✅ All localStorage data cleared!\n\nCheck console for details.');
updateStatus();
}
function navigateToAdmin() {
console.log('🚀 Navigating to /admin...');
window.location.href = '/admin';
}
function navigateToLogin() {
console.log('🚀 Navigating to /admin/login...');
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.');
}
function setExpiredToken() {
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyMzkwMjJ9.invalidexpiredtoken';
console.log('⚠️ Setting expired/invalid token...');
localStorage.setItem('admin_token', expiredToken);
localStorage.setItem('admin_user', JSON.stringify({
id: 1,
username: 'test_expired',
role: 'admin'
}));
console.log('✅ Expired token set');
alert('⚠️ Expired token set!\n\nNow try navigating to dashboard.');
updateStatus();
}
function setValidToken() {
// This is a mock token - won't actually work with backend
const mockToken = 'mock_valid_token_' + Date.now();
console.log('✅ Setting mock valid token...');
localStorage.setItem('admin_token', mockToken);
localStorage.setItem('admin_user', JSON.stringify({
id: 1,
username: 'test_user',
role: 'admin'
}));
console.log('✅ Mock token set (will not work with real backend)');
alert('✅ Mock token set!\n\nNote: This is a fake token and won\'t work with the real backend.');
updateStatus();
}
// Log level control
function setLogLevel(level) {
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;
document.getElementById('currentLoginLevel').textContent = level;
console.log('✅ LOGIN log level set to', level);
} else {
console.warn('⚠️ LOG_LEVEL not found (login.js not loaded)');
}
if (typeof API_LOG_LEVEL !== 'undefined') {
window.API_LOG_LEVEL = level;
document.getElementById('currentApiLevel').textContent = 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}\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();
// 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>

View File

@@ -1,4 +1,4 @@
// static/js/shared/api-client.js // static/shared/js/api-client.js
/** /**
* API Client for Multi-Tenant Ecommerce Platform * API Client for Multi-Tenant Ecommerce Platform
* *
@@ -9,6 +9,16 @@
* - Request/response interceptors * - Request/response interceptors
*/ */
// Log levels: 0 = None, 1 = Error, 2 = Warning, 3 = Info, 4 = Debug
const API_LOG_LEVEL = 3; // Set to 3 for production, 4 for full debugging
const apiLog = {
error: (...args) => API_LOG_LEVEL >= 1 && console.error('❌ [API ERROR]', ...args),
warn: (...args) => API_LOG_LEVEL >= 2 && console.warn('⚠️ [API WARN]', ...args),
info: (...args) => API_LOG_LEVEL >= 3 && console.info(' [API INFO]', ...args),
debug: (...args) => API_LOG_LEVEL >= 4 && console.log('🔍 [API DEBUG]', ...args)
};
const API_BASE_URL = '/api/v1'; const API_BASE_URL = '/api/v1';
/** /**
@@ -17,13 +27,24 @@ const API_BASE_URL = '/api/v1';
class APIClient { class APIClient {
constructor(baseURL = API_BASE_URL) { constructor(baseURL = API_BASE_URL) {
this.baseURL = baseURL; this.baseURL = baseURL;
apiLog.info('API Client initialized with base URL:', baseURL);
} }
/** /**
* Get stored authentication token * Get stored authentication token
*/ */
getToken() { getToken() {
return localStorage.getItem('admin_token') || localStorage.getItem('vendor_token'); const adminToken = localStorage.getItem('admin_token');
const vendorToken = localStorage.getItem('vendor_token');
const token = adminToken || vendorToken;
apiLog.debug('Getting token:', {
hasAdminToken: !!adminToken,
hasVendorToken: !!vendorToken,
usingToken: token ? 'admin or vendor' : 'none'
});
return token;
} }
/** /**
@@ -38,6 +59,9 @@ class APIClient {
const token = this.getToken(); const token = this.getToken();
if (token) { if (token) {
headers['Authorization'] = `Bearer ${token}`; headers['Authorization'] = `Bearer ${token}`;
apiLog.debug('Authorization header added');
} else {
apiLog.debug('No token available, request will be unauthenticated');
} }
return headers; return headers;
@@ -48,6 +72,14 @@ class APIClient {
*/ */
async request(endpoint, options = {}) { async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`; const url = `${this.baseURL}${endpoint}`;
const method = options.method || 'GET';
apiLog.info(`${method} ${url}`);
apiLog.debug('Request options:', {
method,
hasBody: !!options.body,
customHeaders: Object.keys(options.headers || {})
});
const config = { const config = {
...options, ...options,
@@ -55,26 +87,61 @@ class APIClient {
}; };
try { try {
const startTime = Date.now();
const response = await fetch(url, config); const response = await fetch(url, config);
const duration = Date.now() - startTime;
// Handle 401 Unauthorized apiLog.info(`Response: ${response.status} ${response.statusText} (${duration}ms)`);
if (response.status === 401) {
this.handleUnauthorized();
throw new Error('Unauthorized - please login again');
}
// Parse response // Parse response
const data = await response.json(); let data;
try {
data = await response.json();
apiLog.debug('Response data received:', {
hasData: !!data,
dataType: typeof data,
keys: data ? Object.keys(data) : []
});
} catch (parseError) {
apiLog.error('Failed to parse JSON response:', parseError);
throw new Error('Invalid JSON response from server');
}
// Handle 401 Unauthorized - Just clear tokens, DON'T redirect
if (response.status === 401) {
apiLog.warn('401 Unauthorized - Authentication failed');
apiLog.debug('Error details:', data);
apiLog.info('Clearing authentication tokens');
this.clearTokens();
const errorMessage = data.message || data.detail || 'Unauthorized - please login again';
apiLog.error('Throwing authentication error:', errorMessage);
throw new Error(errorMessage);
}
// Handle non-OK responses // Handle non-OK responses
if (!response.ok) { if (!response.ok) {
throw new Error(data.detail || data.message || 'Request failed'); const errorMessage = data.detail || data.message || `Request failed with status ${response.status}`;
apiLog.error('Request failed:', {
status: response.status,
message: errorMessage,
errorCode: data.error_code
});
throw new Error(errorMessage);
} }
apiLog.info('Request completed successfully');
return data; return data;
} catch (error) { } catch (error) {
console.error('API request failed:', error); // Log error details
if (error.name === 'TypeError' && error.message.includes('fetch')) {
apiLog.error('Network error - Failed to connect to server');
} else {
apiLog.error('Request error:', error.message);
}
apiLog.debug('Full error:', error);
throw error; throw error;
} }
} }
@@ -86,6 +153,8 @@ class APIClient {
const queryString = new URLSearchParams(params).toString(); const queryString = new URLSearchParams(params).toString();
const url = queryString ? `${endpoint}?${queryString}` : endpoint; const url = queryString ? `${endpoint}?${queryString}` : endpoint;
apiLog.debug('GET request params:', params);
return this.request(url, { return this.request(url, {
method: 'GET' method: 'GET'
}); });
@@ -95,6 +164,11 @@ class APIClient {
* POST request * POST request
*/ */
async post(endpoint, data = {}) { async post(endpoint, data = {}) {
apiLog.debug('POST request data:', {
hasData: !!data,
dataKeys: Object.keys(data)
});
return this.request(endpoint, { return this.request(endpoint, {
method: 'POST', method: 'POST',
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -105,6 +179,11 @@ class APIClient {
* PUT request * PUT request
*/ */
async put(endpoint, data = {}) { async put(endpoint, data = {}) {
apiLog.debug('PUT request data:', {
hasData: !!data,
dataKeys: Object.keys(data)
});
return this.request(endpoint, { return this.request(endpoint, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -115,31 +194,59 @@ class APIClient {
* DELETE request * DELETE request
*/ */
async delete(endpoint) { async delete(endpoint) {
apiLog.debug('DELETE request');
return this.request(endpoint, { return this.request(endpoint, {
method: 'DELETE' method: 'DELETE'
}); });
} }
/** /**
* Handle unauthorized access * Clear authentication tokens
*/ */
handleUnauthorized() { clearTokens() {
apiLog.info('Clearing all authentication tokens...');
const tokensBefore = {
admin_token: !!localStorage.getItem('admin_token'),
admin_user: !!localStorage.getItem('admin_user'),
vendor_token: !!localStorage.getItem('vendor_token'),
vendor_user: !!localStorage.getItem('vendor_user'),
token: !!localStorage.getItem('token')
};
apiLog.debug('Tokens before clear:', tokensBefore);
localStorage.removeItem('admin_token'); localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user'); localStorage.removeItem('admin_user');
localStorage.removeItem('vendor_token'); localStorage.removeItem('vendor_token');
localStorage.removeItem('vendor_user'); localStorage.removeItem('vendor_user');
localStorage.removeItem('token');
// Redirect to appropriate login page const tokensAfter = {
if (window.location.pathname.includes('/admin/')) { admin_token: !!localStorage.getItem('admin_token'),
window.location.href = '/static/admin/login.html'; admin_user: !!localStorage.getItem('admin_user'),
} else if (window.location.pathname.includes('/vendor/')) { vendor_token: !!localStorage.getItem('vendor_token'),
window.location.href = '/static/vendor/login.html'; vendor_user: !!localStorage.getItem('vendor_user'),
} token: !!localStorage.getItem('token')
};
apiLog.debug('Tokens after clear:', tokensAfter);
apiLog.info('All tokens cleared');
}
/**
* Handle unauthorized access
* DEPRECATED - Now just clears tokens, doesn't redirect
* Server handles redirects via exception handler
*/
handleUnauthorized() {
apiLog.warn('handleUnauthorized called (DEPRECATED) - use clearTokens instead');
this.clearTokens();
} }
} }
// Create global API client instance // Create global API client instance
const apiClient = new APIClient(); const apiClient = new APIClient();
apiLog.info('Global API client created');
/** /**
* Authentication helpers * Authentication helpers
@@ -150,7 +257,9 @@ const Auth = {
*/ */
isAuthenticated() { isAuthenticated() {
const token = localStorage.getItem('admin_token') || localStorage.getItem('vendor_token'); const token = localStorage.getItem('admin_token') || localStorage.getItem('vendor_token');
return !!token; const isAuth = !!token;
apiLog.debug('Auth check:', isAuth ? 'authenticated' : 'not authenticated');
return isAuth;
}, },
/** /**
@@ -158,11 +267,21 @@ const Auth = {
*/ */
getCurrentUser() { getCurrentUser() {
const userStr = localStorage.getItem('admin_user') || localStorage.getItem('vendor_user'); const userStr = localStorage.getItem('admin_user') || localStorage.getItem('vendor_user');
if (!userStr) return null; if (!userStr) {
apiLog.debug('No user found in storage');
return null;
}
try { try {
return JSON.parse(userStr); const user = JSON.parse(userStr);
apiLog.debug('Current user:', {
username: user.username,
role: user.role,
id: user.id
});
return user;
} catch (e) { } catch (e) {
apiLog.error('Failed to parse user data:', e);
return null; return null;
} }
}, },
@@ -172,13 +291,16 @@ const Auth = {
*/ */
isAdmin() { isAdmin() {
const user = this.getCurrentUser(); const user = this.getCurrentUser();
return user && user.role === 'admin'; const isAdmin = user && user.role === 'admin';
apiLog.debug('Admin check:', isAdmin ? 'is admin' : 'not admin');
return isAdmin;
}, },
/** /**
* Login * Login
*/ */
async login(username, password) { async login(username, password) {
apiLog.info('Auth.login called');
const response = await apiClient.post('/auth/login', { const response = await apiClient.post('/auth/login', {
username, username,
password password
@@ -186,9 +308,11 @@ const Auth = {
// Store token and user // Store token and user
if (response.user.role === 'admin') { if (response.user.role === 'admin') {
apiLog.info('Storing admin credentials');
localStorage.setItem('admin_token', response.access_token); localStorage.setItem('admin_token', response.access_token);
localStorage.setItem('admin_user', JSON.stringify(response.user)); localStorage.setItem('admin_user', JSON.stringify(response.user));
} else { } else {
apiLog.info('Storing vendor credentials');
localStorage.setItem('vendor_token', response.access_token); localStorage.setItem('vendor_token', response.access_token);
localStorage.setItem('vendor_user', JSON.stringify(response.user)); localStorage.setItem('vendor_user', JSON.stringify(response.user));
} }
@@ -200,10 +324,9 @@ const Auth = {
* Logout * Logout
*/ */
logout() { logout() {
localStorage.removeItem('admin_token'); apiLog.info('Auth.logout called');
localStorage.removeItem('admin_user'); apiClient.clearTokens();
localStorage.removeItem('vendor_token'); apiLog.info('User logged out');
localStorage.removeItem('vendor_user');
} }
}; };
@@ -269,6 +392,8 @@ const Utils = {
* Show toast notification * Show toast notification
*/ */
showToast(message, type = 'info', duration = 3000) { showToast(message, type = 'info', duration = 3000) {
apiLog.debug('Showing toast:', { message, type, duration });
// Create toast element // Create toast element
const toast = document.createElement('div'); const toast = document.createElement('div');
toast.className = `toast toast-${type}`; toast.className = `toast toast-${type}`;
@@ -374,4 +499,6 @@ if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTableScrollDetection); document.addEventListener('DOMContentLoaded', initTableScrollDetection);
} else { } else {
initTableScrollDetection(); initTableScrollDetection();
} }
apiLog.info('API Client module loaded');

406
static/shared/js/icons.js Normal file
View File

@@ -0,0 +1,406 @@
/**
* Heroicons Helper - Inline SVG Icons
* Usage: icon('home') or icon('home', 'w-6 h-6')
*/
const Icons = {
// Navigation
home: `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>`,
menu: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/>
</svg>`,
search: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/>
</svg>`,
// User & Profile
user: `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>`,
users: `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>`,
// Actions
edit: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/>
</svg>`,
delete: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>`,
plus: `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>`,
check: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>`,
close: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>`,
// Theme & Settings
sun: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"/>
</svg>`,
moon: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg>`,
cog: `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>`,
// Notifications & Communication
bell: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6zM10 18a3 3 0 01-3-3h6a3 3 0 01-3 3z"/>
</svg>`,
mail: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/>
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/>
</svg>`,
// Logout
logout: `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>`,
// Business/Commerce
'shopping-bag': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"/>
</svg>`,
cube: `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>`,
chart: `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"/>
</svg>`,
// Arrows & Directions
'chevron-down': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>`,
'chevron-right': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
</svg>`,
'arrow-left': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>`,
// Status & Indicators
'exclamation': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>`,
'information-circle': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>`,
// Loading
spinner: `<svg class="{{classes}} animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>`,
// E-commerce Specific
'shopping-cart': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M3 1a1 1 0 000 2h1.22l.305 1.222a.997.997 0 00.01.042l1.358 5.43-.893.892C3.74 11.846 4.632 14 6.414 14H15a1 1 0 000-2H6.414l1-1H14a1 1 0 00.894-.553l3-6A1 1 0 0017 3H6.28l-.31-1.243A1 1 0 005 1H3zM16 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM6.5 18a1.5 1.5 0 100-3 1.5 1.5 0 000 3z"/>
</svg>`,
'credit-card': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
</svg>`,
'currency-dollar': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>`,
'gift': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7"/>
</svg>`,
'tag': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
</svg>`,
'truck': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0"/>
</svg>`,
'receipt': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
</svg>`,
'clipboard-list': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>`,
// Inventory & Products
'collection': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>`,
'photograph': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>`,
'color-swatch': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"/>
</svg>`,
'template': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"/>
</svg>`,
// Analytics & Reports
'trending-up': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
</svg>`,
'trending-down': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"/>
</svg>`,
'presentation-chart-line': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"/>
</svg>`,
'calculator': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>`,
// Customer Management
'user-circle': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-6-3a2 2 0 11-4 0 2 2 0 014 0zm-2 4a5 5 0 00-4.546 2.916A5.986 5.986 0 0010 16a5.986 5.986 0 004.546-2.084A5 5 0 0010 11z" clip-rule="evenodd"/>
</svg>`,
'user-group': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"/>
</svg>`,
'identification': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2"/>
</svg>`,
'badge-check': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>`,
// Documents & Files
'document': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>`,
'folder': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/>
</svg>`,
'folder-open': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd"/>
<path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z"/>
</svg>`,
'download': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>`,
'upload': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>`,
// Time & Calendar
'calendar': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>`,
'clock': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>`,
// System & Settings
'database': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"/>
</svg>`,
'server': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/>
</svg>`,
'shield-check': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>`,
'key': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z" clip-rule="evenodd"/>
</svg>`,
'lock-closed': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/>
</svg>`,
'lock-open': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a5 5 0 00-5 5v2a2 2 0 00-2 2v5a2 2 0 002 2h10a2 2 0 002-2v-5a2 2 0 00-2-2H7V7a3 3 0 015.905-.75 1 1 0 001.937-.5A5.002 5.002 0 0010 2z"/>
</svg>`,
// Actions & Interactions
'refresh': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>`,
'duplicate': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>`,
'eye': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>`,
'eye-off': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>`,
'filter': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
</svg>`,
'dots-vertical': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z"/>
</svg>`,
'dots-horizontal': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z"/>
</svg>`,
// Communication
'chat': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>`,
'annotation': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/>
</svg>`,
'phone': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z"/>
</svg>`,
// Location
'location-marker': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"/>
</svg>`,
'globe': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>`,
// Links & External
'external-link': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>`,
'link': `<svg class="{{classes}}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
</svg>`,
// Status Badges
'star': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>`,
'heart': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clip-rule="evenodd"/>
</svg>`,
'flag': `<svg class="{{classes}}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 6a3 3 0 013-3h10a1 1 0 01.8 1.6L14.25 8l2.55 3.4A1 1 0 0116 13H6a1 1 0 00-1 1v3a1 1 0 11-2 0V6z" clip-rule="evenodd"/>
</svg>`
};
/**
* Get icon SVG with custom classes
* @param {string} name - Icon name from Icons object
* @param {string} classes - Tailwind classes (default: 'w-5 h-5')
* @returns {string} SVG markup
*/
function icon(name, classes = 'w-5 h-5') {
const iconTemplate = Icons[name];
if (!iconTemplate) {
console.warn(`Icon "${name}" not found`);
return '';
}
return iconTemplate.replace('{{classes}}', classes);
}
/**
* Alpine.js magic helper
* Usage in Alpine: x-html="$icon('home')" or x-html="$icon('home', 'w-6 h-6')"
*/
if (typeof Alpine !== 'undefined') {
document.addEventListener('alpine:init', () => {
Alpine.magic('icon', () => {
return (name, classes) => icon(name, classes);
});
});
}
// Export for use in modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = { icon, Icons };
}
// Make available globally
window.icon = icon;
window.Icons = Icons;
/**
* Get icon SVG with custom classes
* @param {string} name - Icon name from Icons object
* @param {string} classes - Tailwind classes (default: 'w-5 h-5')
* @returns {string} SVG markup
*/
function icon(name, classes = 'w-5 h-5') {
const iconTemplate = Icons[name];
if (!iconTemplate) {
console.warn(`Icon "${name}" not found`);
return '';
}
return iconTemplate.replace('{{classes}}', classes);
}
/**
* Alpine.js magic helper
* Usage in Alpine: x-html="$icon('home')" or x-html="$icon('home', 'w-6 h-6')"
*/
if (typeof Alpine !== 'undefined') {
document.addEventListener('alpine:init', () => {
Alpine.magic('icon', () => {
return (name, classes) => icon(name, classes);
});
});
}
// Export for use in modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = { icon, Icons };
}
// Make available globally
window.icon = icon;
window.Icons = Icons;

193
static/shared/js/utils.js Normal file
View File

@@ -0,0 +1,193 @@
// static/shared/js/utils.js
/**
* Utility functions for the application
*/
const Utils = {
/**
* Format date for display
* @param {string} dateString - ISO date string
* @returns {string} Formatted date
*/
formatDate(dateString) {
if (!dateString) return '-';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch (error) {
console.error('Error formatting date:', error);
return dateString;
}
},
/**
* Format date with time
* @param {string} dateString - ISO date string
* @returns {string} Formatted date with time
*/
formatDateTime(dateString) {
if (!dateString) return '-';
try {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch (error) {
console.error('Error formatting datetime:', error);
return dateString;
}
},
/**
* Format currency
* @param {number} amount - Amount to format
* @param {string} currency - Currency code (default: USD)
* @returns {string} Formatted currency
*/
formatCurrency(amount, currency = 'USD') {
if (amount === null || amount === undefined) return '-';
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency
}).format(amount);
} catch (error) {
console.error('Error formatting currency:', error);
return amount.toString();
}
},
/**
* Format number with commas
* @param {number} num - Number to format
* @returns {string} Formatted number
*/
formatNumber(num) {
if (num === null || num === undefined) return '0';
return num.toLocaleString('en-US');
},
/**
* Show toast notification
* @param {string} message - Toast message
* @param {string} type - Toast type: 'success', 'error', 'warning', 'info'
* @param {number} duration - Duration in ms (default: 3000)
*/
showToast(message, type = 'info', duration = 3000) {
// Create toast element
const toast = document.createElement('div');
toast.className = `fixed bottom-4 right-4 px-6 py-3 rounded-lg shadow-lg text-white z-50 transition-opacity duration-300 ${
type === 'success' ? 'bg-green-500' :
type === 'error' ? 'bg-red-500' :
type === 'warning' ? 'bg-yellow-500' :
'bg-blue-500'
}`;
toast.textContent = message;
document.body.appendChild(toast);
// Fade out and remove
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, duration);
},
/**
* Debounce function
* @param {Function} func - Function to debounce
* @param {number} wait - Wait time in ms
* @returns {Function} Debounced function
*/
debounce(func, wait = 300) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
/**
* Get query parameter from URL
* @param {string} param - Parameter name
* @returns {string|null} Parameter value
*/
getQueryParam(param) {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get(param);
},
/**
* Copy text to clipboard
* @param {string} text - Text to copy
*/
async copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
this.showToast('Copied to clipboard', 'success');
} catch (error) {
console.error('Failed to copy:', error);
this.showToast('Failed to copy', 'error');
}
},
/**
* Truncate string
* @param {string} str - String to truncate
* @param {number} maxLength - Maximum length
* @returns {string} Truncated string
*/
truncate(str, maxLength = 50) {
if (!str || str.length <= maxLength) return str;
return str.substring(0, maxLength - 3) + '...';
},
/**
* Validate email format
* @param {string} email - Email to validate
* @returns {boolean} Is valid email
*/
isValidEmail(email) {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
},
/**
* Get status badge class
* @param {string} status - Status value
* @returns {string} Tailwind classes for badge
*/
getStatusBadgeClass(status) {
const statusClasses = {
'active': 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100',
'inactive': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-100',
'pending': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100',
'verified': 'bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100',
'rejected': 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100'
};
return statusClasses[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
}
};
// Make available globally
window.Utils = Utils;
// Export for modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = Utils;
}

430
temp.md Normal file
View File

@@ -0,0 +1,430 @@
Project Continuation Guide: Multi-Tenant E-commerce Component System
🎯 Project Overview
We're building a universal component-based architecture for a multi-tenant e-commerce platform with three distinct sections:
Admin Portal - Internal management dashboard
Vendor Dashboard - Business owner portal
Shop Frontend - Customer-facing storefront
Main Goals:
✅ Eliminate code duplication (header, sidebar, modals repeated across pages)
✅ Create consistent UX across all sections
✅ Use Alpine.js components for reusability
✅ Support all three sections from day one (no rework needed later)
✅ Maintain consistent modal behavior everywhere
🏗️ Architecture Overview
Technology Stack:
Frontend: Plain HTML, CSS, JavaScript (no frameworks)
State Management: Alpine.js 3.x
API Client: Custom apiClient class
Backend: FastAPI (Python) with multi-tenant architecture
Component Architecture:
Universal Modal System (shared by all sections)
├── Admin Layout Component
├── Vendor Layout Component
└── Shop Layout Component
└── Shop Account Layout Component
```
### **Key Design Decisions:**
1. **Universal Modals** - Same confirmation/success/error modals work in all sections
2. **Section-Specific Layouts** - Each section has its own header/sidebar/navigation
3. **Shared Utilities** - Common functions (Auth, Utils, apiClient) used everywhere
4. **Session-Based Cart** - No authentication required for shopping
5. **Vendor-Scoped Customers** - Each vendor has independent customer base
---
## 📊 **Current Project Structure**
```
static/
├── css/
│ ├── shared/
│ │ ├── base.css # ✅ Exists
│ │ ├── auth.css # ✅ Exists
│ │ ├── responsive-utilities.css # ✅ Exists
│ │ ├── components.css # 🔄 Needs creation
│ │ └── modals.css # 🔄 Needs creation (optional)
│ ├── admin/
│ │ └── admin.css # ✅ Exists
│ ├── vendor/
│ │ └── vendor.css # ✅ Exists
│ └── shop/
│ └── shop.css # 🔄 Needs creation
├── js/
│ ├── shared/
│ │ ├── api-client.js # ✅ Exists (working)
│ │ ├── alpine-components.js # 🔄 IN PROGRESS
│ │ └── modal-system.js # 🔄 Needs creation
│ ├── admin/
│ │ ├── dashboard.js # ✅ Exists (needs conversion)
│ │ ├── vendor-edit.js # ✅ Exists (partially converted)
│ │ ├── vendors.js # ✅ Exists (needs conversion)
│ │ └── login.js # ✅ Exists (working)
│ ├── vendor/
│ │ ├── dashboard.js # ✅ Exists
│ │ ├── products.js # ✅ Exists
│ │ └── orders.js # ✅ Exists
│ └── shop/
│ ├── catalog.js # 🔄 Needs creation
│ ├── product-detail.js # 🔄 Needs creation
│ └── cart.js # 🔄 Needs creation
├── admin/
│ ├── dashboard.html # ✅ Exists (needs conversion)
│ ├── vendor-edit.html # ✅ Exists (partially converted)
│ ├── vendors.html # ✅ Exists (needs conversion)
│ ├── users.html # ✅ Exists (needs conversion)
│ ├── marketplace.html # ✅ Exists
│ ├── monitoring.html # ✅ Exists
│ └── login.html # ✅ Exists (working)
├── vendor/
│ ├── dashboard.html # ✅ Exists (needs conversion)
│ ├── (admin pages) # ✅ Exist (need conversion)
│ └── login.html # ✅ Exists
└── shop/
├── home.html # ✅ Exists (needs conversion)
├── products.html # ✅ Exists (needs conversion)
├── product.html # ✅ Exists (needs conversion)
├── cart.html # ✅ Exists (needs conversion)
└── account/
├── orders.html # ✅ Exists (needs conversion)
├── profile.html # ✅ Exists
├── addresses.html # ✅ Exists
└── login.html # ✅ Exists
✅ What's Been Completed
1. Problem Identification
✅ Identified code duplication issue (header/sidebar/modals repeated)
✅ Analyzed current structure (7 admin pages, ~1,600 lines of duplicated code)
✅ Calculated 85% code reduction potential with component system
2. Architecture Design
✅ Designed universal modal system (works in admin, vendor, shop)
✅ Planned section-specific layouts (admin, vendor, shop, shop-account)
✅ Created component inheritance structure (baseModalSystem)
✅ Planned API integration strategy
3. Initial Implementation
✅ Started alpine-components.js with:
baseModalSystem() - Universal modal functions
adminLayout() - Admin header, sidebar, logout
vendorLayout() - Vendor header, sidebar, logout
shopLayout() - Shop header, cart, search, logout
shopAccountLayout() - Shop account area layout
4. Admin Section Progress
✅ vendor-edit.html - Partially converted
Has custom modals (confirm, success) working
Logout modal working
Needs migration to universal component
✅ vendor-edit.js - Working with modals
✅ Identified all admin pages needing conversion
🔄 Current Status: IN PROGRESS
Last Working On:
Creating the complete alpine-components.js file with all layout components.
What Was Just Completed:
javascript// alpine-components.js structure:
✅ baseModalSystem() - Universal modals (confirm, success, error)
✅ adminLayout() - Complete admin layout with logout modal
✅ vendorLayout() - Complete vendor layout with logout modal
✅ shopLayout() - Complete shop layout with cart integration
✅ shopAccountLayout() - Shop account area layout
// Features implemented:
✅ Session-based cart (no auth required)
✅ Vendor detection and context
✅ Logout confirmation modals for all sections
✅ Cart count tracking
✅ Search functionality
✅ Mobile menu support
```
### **File Status:**
- **`alpine-components.js`** - ✅ **95% COMPLETE** (ready to save)
- **`modal-system.js`** - 🔄 Ready to create next
- **`components.css`** - 🔄 Ready to create next
---
## 🚀 **Next Steps**
### **Immediate Next Actions:**
#### **Step 1: Complete Core Files (30 mins)**
1. ✅ Save `alpine-components.js` (already created)
2. 🔄 Create `modal-system.js` - Helper functions for modals
3. 🔄 Create `components.css` - Universal component styles
4. 🔄 Create `modals.css` - Modal-specific styles (optional)
#### **Step 2: Create Modal HTML Templates (15 mins)**
Create reusable modal HTML snippets that can be copy-pasted into pages:
- Confirmation Modal template
- Success Modal template
- Error Modal template
- Loading Overlay template
#### **Step 3: Convert Admin Pages (2 hours)**
Convert pages to use new component system:
1. **`vendor-edit.html`** (30 mins) - Already partially done
- Replace duplicated modals with component
- Use `adminLayout()` component
- Test all functionality
2. **`dashboard.html`** (30 mins)
- Add `adminLayout()` component
- Add modal templates
- Update logout button
3. **`vendors.html`** (30 mins)
- Same conversion pattern
- Test vendor management features
4. **`users.html`** (30 mins)
- Same conversion pattern
#### **Step 4: Create Shop Pages (3 hours)**
1. **`home.html`** (45 mins)
- Use `shopLayout()` component
- Integrate vendor detection
- Add featured products section
2. **`products.html`** (45 mins)
- Product catalog with filters
- Integrate with `/public/vendors/{vendor_id}/products` API
- Add to cart functionality
3. **`product.html`** (45 mins)
- Product detail page
- Image gallery
- Add to cart
- Quantity selector
4. **`cart.html`** (45 mins)
- Shopping cart display
- Update quantities
- Remove items
- Checkout button
5. **`account/orders.html`** (45 mins)
- Use `shopAccountLayout()` component
- Order history display
- Order detail links
#### **Step 5: Create Shop JavaScript (2 hours)**
1. **`catalog.js`** - Product listing logic
2. **`product-detail.js`** - Single product logic
3. **`cart.js`** - Cart management
#### **Step 6: Convert Vendor Pages (1.5 hours)**
1. **`dashboard.html`** - Use `vendorLayout()`
2. **`products.html`** - Product management
3. **`orders.html`** - Order management
---
## 📡 **API Integration Details**
### **Backend API Endpoints Available:**
#### **Vendor APIs:**
```
GET /api/v1/public/vendors/by-code/{vendor_code}
GET /api/v1/public/vendors/by-subdomain/{subdomain}
GET /api/v1/public/vendors/{vendor_id}/info
```
#### **Product APIs:**
```
GET /api/v1/public/vendors/{vendor_id}/products
?skip=0&limit=100&search=query&is_featured=true
GET /api/v1/public/vendors/{vendor_id}/products/{product_id}
GET /api/v1/public/vendors/{vendor_id}/products/search?q=query
```
#### **Cart APIs (Session-based, no auth required):**
```
GET /api/v1/public/vendors/{vendor_id}/cart/{session_id}
POST /api/v1/public/vendors/{vendor_id}/cart/{session_id}/items
Body: { product_id: 1, quantity: 2 }
PUT /api/v1/public/vendors/{vendor_id}/cart/{session_id}/items/{product_id}
Body: { quantity: 3 }
DELETE /api/v1/public/vendors/{vendor_id}/cart/{session_id}/items/{product_id}
DELETE /api/v1/public/vendors/{vendor_id}/cart/{session_id}
```
#### **Customer Auth APIs:**
```
POST /api/v1/public/vendors/{vendor_id}/customers/register
POST /api/v1/public/vendors/{vendor_id}/customers/login
POST /api/v1/public/vendors/{vendor_id}/customers/logout
```
#### **Order APIs:**
```
POST /api/v1/public/vendors/{vendor_id}/orders
GET /api/v1/public/vendors/{vendor_id}/customers/{customer_id}/orders
GET /api/v1/public/vendors/{vendor_id}/customers/{customer_id}/orders/{order_id}
Key API Features:
✅ Multi-tenant (vendor-scoped)
✅ Session-based cart (no login required for shopping)
✅ Vendor-scoped customers (same email can register with different vendors)
✅ Public product catalog (no auth needed to browse)
✅ Active/inactive vendor filtering
💡 Important Context
User's Preferences:
Uses Python for backend
Uses plain HTML/CSS/JavaScript for frontend (no frameworks like React/Vue)
Uses Alpine.js for reactivity
Prefers AJAX over full-page reloads
Wants clean, maintainable code
Current Working Features:
✅ Admin login/logout working
✅ Admin dashboard displaying stats
✅ Vendor edit page working with custom modals
✅ API client properly configured
✅ Authentication system working
Known Issues Fixed:
✅ Fixed duplicate /api/v1/ in URLs (was /api/v1/api/v1/)
✅ Fixed admin auth endpoint (uses /admin/auth/me not /auth/me)
✅ Fixed emoji encoding in logs (removed emojis from Python logging)
✅ Fixed Alpine timing issues (initialize vendor as {} not null)
✅ Fixed modal stacking (transfer ownership modal)
📝 Code Patterns Established
Component Usage Pattern:
html<!-- Admin Page -->
<body x-data="adminLayout()" x-init="init()">
<!-- Layout renders header + sidebar + modals -->
<main class="admin-content">
<!-- Page-specific content here -->
</main>
</body>
Modal Usage Pattern:
javascript// In any component/page using adminLayout()
this.showConfirmModal({
title: 'Confirm Action',
message: 'Are you sure?',
warning: 'This cannot be undone',
buttonText: 'Yes, Do It',
buttonClass: 'btn-danger',
onConfirm: () => this.doAction()
});
API Call Pattern:
javascript// Using the existing apiClient
const products = await apiClient.get(
`/public/vendors/${vendorId}/products`,
{ skip: 0, limit: 20 }
);
🎯 Success Criteria
The project will be complete when:
✅ All admin pages use adminLayout() component
✅ All vendor pages use vendorLayout() component
✅ All shop pages use shopLayout() or shopAccountLayout()
✅ Modals work consistently across all sections
✅ No code duplication for headers/sidebars/modals
✅ Shop can browse products, add to cart, checkout
✅ Cart persists across page refreshes (session-based)
✅ Customers can register/login per vendor
✅ Customers can view order history
📦 Files Ready to Deliver
When you continue, ask for these files in this order:
Phase 1: Core System
static/js/shared/alpine-components.js - ✅ Ready (95% complete)
static/js/shared/modal-system.js - Ready to create
static/css/shared/components.css - Ready to create
Phase 2: Modal Templates
Modal HTML templates (copy-paste snippets)
Phase 3: Admin Pages
static/admin/vendor-edit.html - Updated version
static/admin/dashboard.html - Converted version
static/admin/vendors.html - Converted version
Phase 4: Shop Pages
static/shop/home.html - New with shopLayout
static/shop/products.html - Converted
static/shop/product.html - Converted
static/shop/cart.html - Converted
static/shop/account/orders.html - Converted
Phase 5: Shop JavaScript
static/js/shop/catalog.js
static/js/shop/product-detail.js
static/js/shop/cart.js
Phase 6: Documentation
Migration guide for remaining pages
Component usage documentation
🚀 How to Continue
In your next chat, say:
"Let's continue the component system implementation. I have the continuation guide. Please start with Phase 1: create alpine-components.js, modal-system.js, and components.css."
Or ask for specific phases:
"Give me Phase 1 files (core system)"
"Give me Phase 3 files (admin pages)"
"Give me Phase 4 files (shop pages)"
📊 Progress Tracking
Overall Progress: 25% complete
Core System: 60% complete
Admin Section: 40% complete
Vendor Section: 10% complete
Shop Section: 5% complete
Estimated Time to Complete: 8-10 hours of work remaining
Last Updated: Current session
Ready to Continue: Yes - All context preserved
Next Action: Create Phase 1 core files