Working state before icon/utils fixes - Oct 22
This commit is contained in:
4
.idea/misc.xml
generated
4
.idea/misc.xml
generated
@@ -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
2
.idea/modules.xml
generated
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
1210
15.web-architecture-revamping.md
Normal file
1210
15.web-architecture-revamping.md
Normal file
File diff suppressed because it is too large
Load Diff
399
16.jinja2_migration_progress-2.md
Normal file
399
16.jinja2_migration_progress-2.md
Normal 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
|
||||||
520
16.jinja2_migration_progress.md
Normal file
520
16.jinja2_migration_progress.md
Normal 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
649
admin_integration_guide.md
Normal 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!
|
||||||
128
app/api/deps.py
128
app/api/deps.py
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
110
app/api/v1/admin/pages.py
Normal 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,
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
60
app/templates/admin/base.html
Normal file
60
app/templates/admin/base.html
Normal 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>
|
||||||
172
app/templates/admin/dashboard.html
Normal file
172
app/templates/admin/dashboard.html
Normal 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 %}
|
||||||
109
app/templates/admin/login.html
Normal file
109
app/templates/admin/login.html
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
98
frontend-structure.txt
Normal 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
27
main.py
@@ -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)
|
||||||
|
|||||||
@@ -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
0
scripts/init_db.py
Normal 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>
|
||||||
@@ -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');
|
||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
95
static/admin/oldlogin.html
Normal file
95
static/admin/oldlogin.html
Normal 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>
|
||||||
72
static/admin/partials/base-layout.html
Normal file
72
static/admin/partials/base-layout.html
Normal 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>
|
||||||
644
static/admin/test-auth-flow.html
Normal file
644
static/admin/test-auth-flow.html
Normal 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>
|
||||||
@@ -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
406
static/shared/js/icons.js
Normal 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
193
static/shared/js/utils.js
Normal 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
430
temp.md
Normal 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
|
||||||
Reference in New Issue
Block a user