Revamp the dashboard interface

parent 59a08356
...@@ -2,25 +2,21 @@ ...@@ -2,25 +2,21 @@
A modular proxy server for managing multiple AI provider integrations with unified API interface. AISBF provides intelligent routing, load balancing, and AI-assisted model selection to optimize AI service usage across multiple providers. A modular proxy server for managing multiple AI provider integrations with unified API interface. AISBF provides intelligent routing, load balancing, and AI-assisted model selection to optimize AI service usage across multiple providers.
## 🚀 Support AISBF - Your Donations Matter! ---
The project includes multiple donation options to support its development: ## 🌐 Try AISBF Now — No Installation Required!
### Ethereum Donation > **[➡️ Launch AISBF at https://aisbf.cloud](https://aisbf.cloud)**
ETH to `0xdA6dAb526515b5cb556d20269207D43fcc760E51` >
> The fully hosted service is free to use. Just open your browser and start routing AI requests across all supported providers — no setup, no configuration, no API keys needed to get started.
### PayPal Donation Also available via TOR for privacy-first access:
https://paypal.me/nexlab [http://aisbfity4ud6nsht53tsh2iauaur2e4dah2gplcprnikyjpkg72vfjad.onion](http://aisbfity4ud6nsht53tsh2iauaur2e4dah2gplcprnikyjpkg72vfjad.onion)
### Bitcoin Donation
Address: `bc1qcpt2uutqkz4456j5r78rjm3gwq03h5fpwmcc5u`
## Try AISBF
Try AISBF live at [https://aisbf.cloud](https://aisbf.cloud) or via TOR at [http://aisbfity4ud6nsht53tsh2iauaur2e4dah2gplcprnikyjpkg72vfjad.onion](http://aisbfity4ud6nsht53tsh2iauaur2e4dah2gplcprnikyjpkg72vfjad.onion) - no installation required!
![AISBF Dashboard](https://git.nexlab.net/nexlab/aisbf/raw/master/screenshot.png) ![AISBF Dashboard](https://git.nexlab.net/nexlab/aisbf/raw/master/screenshot.png)
---
## Key Features ## Key Features
- **Multi-Provider Support**: Unified interface for Google, OpenAI, Anthropic, Claude Code (OAuth2), Ollama, Kiro, Kilocode, Codex, and Qwen - **Multi-Provider Support**: Unified interface for Google, OpenAI, Anthropic, Claude Code (OAuth2), Ollama, Kiro, Kilocode, Codex, and Qwen
...@@ -103,6 +99,19 @@ For complete documentation, configuration guides, and API reference: ...@@ -103,6 +99,19 @@ For complete documentation, configuration guides, and API reference:
- **[🔌 API Reference](DOCUMENTATION.md#api-endpoints)** - Complete API documentation - **[🔌 API Reference](DOCUMENTATION.md#api-endpoints)** - Complete API documentation
- **[🛠️ Development](DOCUMENTATION.md#development)** - Development and deployment guides - **[🛠️ Development](DOCUMENTATION.md#development)** - Development and deployment guides
## 🚀 Support AISBF - Your Donations Matter!
The project includes multiple donation options to support its development:
### Ethereum Donation
ETH to `0xdA6dAb526515b5cb556d20269207D43fcc760E51`
### PayPal Donation
https://paypal.me/nexlab
### Bitcoin Donation
Address: `bc1qcpt2uutqkz4456j5r78rjm3gwq03h5fpwmcc5u`
## Author ## Author
Stefy Lanza <stefy@nexlab.net> Stefy Lanza <stefy@nexlab.net>
......
...@@ -2291,7 +2291,11 @@ async def dashboard_analytics( ...@@ -2291,7 +2291,11 @@ async def dashboard_analytics(
user_filter_int = current_user_id user_filter_int = current_user_id
# Get all users for filter dropdown (only for admins) # Get all users for filter dropdown (only for admins)
all_users = db.get_users() if db and is_admin else [] raw_users = db.get_users() if db and is_admin else []
all_users = [
{k: (v.isoformat() if isinstance(v, datetime) else v) for k, v in u.items()}
for u in raw_users
]
# Get available providers, models, rotations, and autoselects for filter dropdowns # Get available providers, models, rotations, and autoselects for filter dropdowns
available_providers = list(config.providers.keys()) if config else [] available_providers = list(config.providers.keys()) if config else []
...@@ -2379,7 +2383,8 @@ async def dashboard_analytics( ...@@ -2379,7 +2383,8 @@ async def dashboard_analytics(
"selected_rotation": rotation_filter, "selected_rotation": rotation_filter,
"selected_autoselect": autoselect_filter, "selected_autoselect": autoselect_filter,
"selected_user": user_filter, "selected_user": user_filter,
"global_only": global_only "global_only": global_only,
"currency_symbol": DatabaseRegistry.get_config_database().get_currency_settings().get('currency_symbol', '$')
} }
) )
...@@ -2500,6 +2505,7 @@ async def dashboard_login(request: Request, username: str = Form(...), password: ...@@ -2500,6 +2505,7 @@ async def dashboard_login(request: Request, username: str = Form(...), password:
logger.info(f"User authenticated: username={username}, email={user.get('email')}, user_id={user['id']}") logger.info(f"User authenticated: username={username}, email={user.get('email')}, user_id={user['id']}")
request.session['logged_in'] = True request.session['logged_in'] = True
request.session['username'] = username request.session['username'] = username
request.session['display_name'] = user.get('display_name') or ''
request.session['email'] = user.get('email') or '' # Ensure we get the email from user dict request.session['email'] = user.get('email') or '' # Ensure we get the email from user dict
request.session['role'] = user['role'] request.session['role'] = user['role']
request.session['user_id'] = user['id'] request.session['user_id'] = user['id']
...@@ -3203,8 +3209,9 @@ async def dashboard_profile_save(request: Request, username: str = Form(...), di ...@@ -3203,8 +3209,9 @@ async def dashboard_profile_save(request: Request, username: str = Form(...), di
try: try:
db.update_user_profile(user_id, username, None, display_name if display_name else None) db.update_user_profile(user_id, username, None, display_name if display_name else None)
# Update session with new username # Update session with new username and display_name
request.session['username'] = username request.session['username'] = username
request.session['display_name'] = display_name or ''
return RedirectResponse(url=url_for(request, "/dashboard/profile?success=Profile updated successfully"), status_code=303) return RedirectResponse(url=url_for(request, "/dashboard/profile?success=Profile updated successfully"), status_code=303)
except Exception as e: except Exception as e:
...@@ -4031,7 +4038,8 @@ async def dashboard_index(request: Request): ...@@ -4031,7 +4038,8 @@ async def dashboard_index(request: Request):
"subscription": subscription, "subscription": subscription,
"current_tier": current_tier, "current_tier": current_tier,
"payment_methods": payment_methods, "payment_methods": payment_methods,
"currency_symbol": currency_symbol "currency_symbol": currency_symbol,
"display_name": (db.get_user_by_id(user_id) or {}).get('display_name') or request.session.get('username', '') if user_id else request.session.get('username', '')
} }
) )
...@@ -8047,7 +8055,8 @@ async def dashboard_admin_payment_settings(request: Request): ...@@ -8047,7 +8055,8 @@ async def dashboard_admin_payment_settings(request: Request):
name="dashboard/admin_payment_settings.html", name="dashboard/admin_payment_settings.html",
context={ context={
"request": request, "request": request,
"session": request.session "session": request.session,
"currency_symbol": DatabaseRegistry.get_config_database().get_currency_settings().get('currency_symbol', '$')
} }
) )
...@@ -8146,11 +8155,16 @@ async def dashboard_wallet(request: Request): ...@@ -8146,11 +8155,16 @@ async def dashboard_wallet(request: Request):
all_gateways = db.get_payment_gateway_settings() all_gateways = db.get_payment_gateway_settings()
enabled_gateways = {k: v for k, v in all_gateways.items() if v.get('enabled', False)} enabled_gateways = {k: v for k, v in all_gateways.items() if v.get('enabled', False)}
return templates.TemplateResponse("dashboard/wallet.html", { return templates.TemplateResponse(
"request": request, request=request,
"wallet": wallet, name="dashboard/wallet.html",
"enabled_gateways": enabled_gateways, context={
}) "request": request,
"wallet": wallet,
"enabled_gateways": enabled_gateways,
"currency_symbol": db.get_currency_settings().get('currency_symbol', '$'),
}
)
except ImportError: except ImportError:
return HTMLResponse("Wallet functionality not available", status_code=503) return HTMLResponse("Wallet functionality not available", status_code=503)
except Exception as e: except Exception as e:
...@@ -8329,7 +8343,8 @@ async def dashboard_billing(request: Request): ...@@ -8329,7 +8343,8 @@ async def dashboard_billing(request: Request):
"payment_methods": payment_methods, "payment_methods": payment_methods,
"transactions": transactions, "transactions": transactions,
"enabled_gateways": enabled_gateways, "enabled_gateways": enabled_gateways,
"wallet": wallet "wallet": wallet,
"currency_symbol": currency_settings.get('currency_symbol', '$')
} }
) )
......
...@@ -138,20 +138,21 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -138,20 +138,21 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
}); });
function restartServer() { function restartServer() {
if (confirm('Are you sure you want to restart the server? This will disconnect all active connections.')) { showConfirm('Are you sure you want to restart the server? This will disconnect all active connections.', 'Restart Server', '🔄', 'warn').then(ok => {
if (!ok) return;
fetch('{{ url_for(request, "/dashboard/restart") }}', { fetch('{{ url_for(request, "/dashboard/restart") }}', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'} headers: {'Content-Type': 'application/json'}
}) })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
alert(data.message + ' The page will reload in 5 seconds.'); showAlert(data.message + ' The page will reload in 5 seconds.', 'Restarting');
setTimeout(() => window.location.reload(), 5000); setTimeout(() => window.location.reload(), 5000);
}) })
.catch(error => { .catch(error => {
alert('Error restarting server: ' + error); showAlert('Error restarting server: ' + error, 'Error', '❌', 'danger');
}); });
} });
} }
// Snake Game Easter Egg // Snake Game Easter Egg
...@@ -418,9 +419,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -418,9 +419,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
function gameOver() { function gameOver() {
clearInterval(gameLoop); clearInterval(gameLoop);
alert('Game Over! Score: ' + score + '\nPress OK to play again.'); showAlert('Game Over! Score: ' + score, 'Game Over 🐍').then(() => {
startScreen.classList.remove('hidden'); startScreen.classList.remove('hidden');
scoreDisplay.style.display = 'none'; scoreDisplay.style.display = 'none';
});
} }
// Keyboard controls // Keyboard controls
...@@ -465,6 +467,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -465,6 +467,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<h1>AISBF Dashboard <span style="font-size: 0.8em; font-weight: normal; opacity: 0.7;">v{{ __version__ }}</span></h1> <h1>AISBF Dashboard <span style="font-size: 0.8em; font-weight: normal; opacity: 0.7;">v{{ __version__ }}</span></h1>
{% if request.session.logged_in %} {% if request.session.logged_in %}
<div class="header-actions"> <div class="header-actions">
{% if is_aisbf_cloud %}
<button onclick="openContactModal()" class="btn btn-secondary">Help</button>
{% endif %}
<a href="{{ url_for(request, '/dashboard/docs') }}" class="btn btn-secondary">Docs</a> <a href="{{ url_for(request, '/dashboard/docs') }}" class="btn btn-secondary">Docs</a>
<a href="{{ url_for(request, '/dashboard/about') }}" class="btn btn-secondary">About</a> <a href="{{ url_for(request, '/dashboard/about') }}" class="btn btn-secondary">About</a>
<a href="{{ url_for(request, '/dashboard/license') }}" class="btn btn-secondary">License</a> <a href="{{ url_for(request, '/dashboard/license') }}" class="btn btn-secondary">License</a>
...@@ -572,24 +577,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -572,24 +577,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
box-shadow: 0 4px 15px rgba(245, 87, 108, 0.4); box-shadow: 0 4px 15px rgba(245, 87, 108, 0.4);
} }
.donate-modal { /* ── Shared modal backdrop ── */
.donate-modal, .ui-modal-backdrop {
display: none; display: none;
position: fixed; position: fixed;
top: 0; top: 0; left: 0;
left: 0; width: 100%; height: 100%;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7); background: rgba(0,0,0,0.7);
z-index: 1000; z-index: 1000;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.donate-modal.active, .ui-modal-backdrop.active { display: flex; }
.donate-modal.active {
display: flex; .donate-modal-content, .ui-modal-box {
}
.donate-modal-content {
background: #16213e; background: #16213e;
padding: 30px; padding: 30px;
border-radius: 12px; border-radius: 12px;
...@@ -600,14 +601,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -600,14 +601,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
border: 1px solid #0f3460; border: 1px solid #0f3460;
} }
.donate-modal-header { .donate-modal-header, .ui-modal-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 20px; margin-bottom: 20px;
} }
.donate-modal-header h3 { .donate-modal-header h3, .ui-modal-header h3 {
color: #f5576c; color: #f5576c;
margin: 0; margin: 0;
} }
...@@ -619,6 +620,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -619,6 +620,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
font-size: 24px; font-size: 24px;
cursor: pointer; cursor: pointer;
} }
/* ── ui-modal specific ── */
.ui-modal-body { color: #e0e0e0; line-height: 1.5; margin-bottom: 20px; }
.ui-modal-input {
width: 100%; padding: 10px;
border: 1px solid #0f3460; border-radius: 4px;
font-size: 14px; background: #1a1a2e; color: #e0e0e0;
margin-top: 10px;
}
.ui-modal-footer { display: flex; gap: 10px; justify-content: flex-end; }
.ui-modal-icon { font-size: 2em; margin-bottom: 10px; display: block; text-align: center; }
.ui-modal-icon.warn { color: #f39c12; }
.ui-modal-icon.info { color: #3b82f6; }
.ui-modal-icon.danger { color: #e74c3c; }
.crypto-address { .crypto-address {
background: #0f3460; background: #0f3460;
...@@ -909,6 +924,100 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -909,6 +924,100 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
} }
</script> </script>
<!-- Global UI Modal System -->
<div class="ui-modal-backdrop" id="uiModal">
<div class="ui-modal-box" style="max-width:420px;">
<div class="ui-modal-header">
<h3 id="uiModalTitle">Notice</h3>
<button class="close-modal" id="uiModalClose">&times;</button>
</div>
<div class="ui-modal-body">
<span id="uiModalIcon" class="ui-modal-icon" style="display:none;"></span>
<div id="uiModalMsg"></div>
<input class="ui-modal-input" id="uiModalInput" type="text" style="display:none;" placeholder="">
</div>
<div class="ui-modal-footer" id="uiModalFooter"></div>
</div>
</div>
<script>
(function(){
const backdrop = document.getElementById('uiModal');
const titleEl = document.getElementById('uiModalTitle');
const iconEl = document.getElementById('uiModalIcon');
const msgEl = document.getElementById('uiModalMsg');
const inputEl = document.getElementById('uiModalInput');
const footerEl = document.getElementById('uiModalFooter');
const closeBtn = document.getElementById('uiModalClose');
let _resolve = null;
function open(opts) {
titleEl.textContent = opts.title || 'Notice';
msgEl.textContent = opts.message || '';
if (opts.icon) { iconEl.className = 'ui-modal-icon ' + (opts.iconClass||'info'); iconEl.textContent = opts.icon; iconEl.style.display='block'; }
else { iconEl.style.display='none'; }
inputEl.style.display = opts.prompt ? 'block' : 'none';
inputEl.value = opts.defaultValue || '';
inputEl.placeholder = opts.placeholder || '';
footerEl.innerHTML = '';
(opts.buttons||[]).forEach(b => {
const btn = document.createElement('button');
btn.textContent = b.label;
btn.className = 'btn ' + (b.cls||'btn-secondary');
btn.onclick = () => { close(); _resolve(b.value); };
footerEl.appendChild(btn);
});
backdrop.classList.add('active');
if (opts.prompt) setTimeout(()=>inputEl.focus(),50);
}
function close() { backdrop.classList.remove('active'); }
closeBtn.onclick = () => { close(); if(_resolve) _resolve(null); };
backdrop.addEventListener('click', e => { if(e.target===backdrop){ close(); if(_resolve) _resolve(null); } });
window.showAlert = function(message, title, icon, iconClass) {
return new Promise(res => {
_resolve = res;
open({ title: title||'Notice', message, icon: icon||'ℹ️', iconClass: iconClass||'info',
buttons: [{label:'OK', value:true, cls:'btn'}] });
});
};
window.showConfirm = function(message, title, icon, iconClass) {
return new Promise(res => {
_resolve = res;
open({ title: title||'Confirm', message, icon: icon||'⚠️', iconClass: iconClass||'warn',
buttons: [
{label:'Cancel', value:false, cls:'btn-secondary'},
{label:'Confirm', value:true, cls:'btn'}
] });
});
};
window.showDangerConfirm = function(message, title) {
return new Promise(res => {
_resolve = res;
open({ title: title||'Warning', message, icon:'🗑️', iconClass:'danger',
buttons: [
{label:'Cancel', value:false, cls:'btn-secondary'},
{label:'Delete', value:true, cls:'btn btn-danger'}
] });
});
};
window.showPrompt = function(message, defaultValue, placeholder, title) {
return new Promise(res => {
_resolve = v => res(v === null ? null : inputEl.value || null);
open({ title: title||'Input', message, prompt:true, defaultValue, placeholder,
buttons: [
{label:'Cancel', value:null, cls:'btn-secondary'},
{label:'OK', value:true, cls:'btn'}
] });
// OK button needs to read input value
const okBtn = footerEl.lastChild;
okBtn.onclick = () => { const v = inputEl.value.trim(); close(); res(v||null); };
});
};
})();
</script>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
</body> </body>
</html> </html>
...@@ -140,7 +140,7 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -140,7 +140,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (data.success) { if (data.success) {
window.location.href = '/dashboard/billing?success=' + encodeURIComponent(data.message); window.location.href = '/dashboard/billing?success=' + encodeURIComponent(data.message);
} else { } else {
alert('Error: ' + data.error); showAlert('Error: ' + data.error, 'Error', '❌', 'danger');
} }
}); });
}); });
...@@ -165,7 +165,7 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -165,7 +165,7 @@ document.addEventListener('DOMContentLoaded', function() {
const stripeKey = '{{ stripe_publishable_key }}'; const stripeKey = '{{ stripe_publishable_key }}';
if (!stripeKey) { if (!stripeKey) {
alert('Stripe is not configured. Please contact the administrator.'); showAlert('Stripe is not configured. Please contact the administrator.', 'Not Configured', '⚠️', 'warn');
return; return;
} }
......
...@@ -19,6 +19,49 @@ ...@@ -19,6 +19,49 @@
</div> </div>
</div> </div>
<!-- Global Currency Settings -->
<div style="background: #16213e; border: 2px solid #17a2b8; border-radius: 8px; padding: 20px; margin-bottom: 20px;">
<h3 style="margin: 0 0 20px 0; color: #17a2b8;">
<i class="fas fa-dollar-sign me-2"></i>Global Currency Settings
</h3>
<form id="currencySettingsForm">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px; margin-bottom: 20px;">
<div>
<label style="display: block; margin-bottom: 5px; font-weight: 500; color: #e0e0e0;">Currency Code</label>
<select style="width: 100%; padding: 8px; border: 1px solid #0f3460; border-radius: 3px; font-size: 14px; background: #1a1a2e; color: #e0e0e0;" id="currencyCode">
<option value="USD">USD - US Dollar</option>
<option value="EUR">EUR - Euro</option>
<option value="GBP">GBP - British Pound</option>
<option value="JPY">JPY - Japanese Yen</option>
<option value="CAD">CAD - Canadian Dollar</option>
<option value="AUD">AUD - Australian Dollar</option>
<option value="CHF">CHF - Swiss Franc</option>
<option value="CNY">CNY - Chinese Yuan</option>
<option value="INR">INR - Indian Rupee</option>
<option value="BRL">BRL - Brazilian Real</option>
</select>
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: 500; color: #e0e0e0;">Currency Symbol</label>
<input type="text" style="width: 100%; padding: 8px; border: 1px solid #0f3460; border-radius: 3px; font-size: 14px; background: #1a1a2e; color: #e0e0e0;" id="currencySymbol" value="{{ currency_symbol }}">
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: 500; color: #e0e0e0;">Decimal Places</label>
<select style="width: 100%; padding: 8px; border: 1px solid #0f3460; border-radius: 3px; font-size: 14px; background: #1a1a2e; color: #e0e0e0;" id="currencyDecimals">
<option value="0">0</option>
<option value="1">1</option>
<option value="2" selected>2</option>
<option value="3">3</option>
</select>
</div>
</div>
<button type="submit" class="btn" style="background: #17a2b8; color: white;">
<i class="fas fa-save me-2"></i>Save Currency Settings
</button>
</form>
</div>
<!-- Encryption Key Configuration --> <!-- Encryption Key Configuration -->
<div style="background: #16213e; border: 2px solid #e74c3c; border-radius: 8px; padding: 20px; margin-bottom: 20px;"> <div style="background: #16213e; border: 2px solid #e74c3c; border-radius: 8px; padding: 20px; margin-bottom: 20px;">
<h3 style="margin: 0 0 20px 0; color: #e74c3c;"> <h3 style="margin: 0 0 20px 0; color: #e74c3c;">
...@@ -638,9 +681,8 @@ async function saveEncryptionKey() { ...@@ -638,9 +681,8 @@ async function saveEncryptionKey() {
return; return;
} }
if (!confirm('WARNING: Setting or changing the encryption key will affect all encrypted data. Are you sure?')) { const ok = await showDangerConfirm('WARNING: Setting or changing the encryption key will affect all encrypted data. Are you sure?', 'Change Encryption Key');
return; if (!ok) return;
}
try { try {
const response = await fetch('{{ url_for(request, "/api/admin/settings/encryption-key") }}', { const response = await fetch('{{ url_for(request, "/api/admin/settings/encryption-key") }}', {
...@@ -678,7 +720,7 @@ async function loadSystemStatus() { ...@@ -678,7 +720,7 @@ async function loadSystemStatus() {
} }
// Update balances // Update balances
document.getElementById('totalBalance').textContent = '$' + (data.total_balance_usd || 0).toFixed(2); document.getElementById('totalBalance').textContent = '{{ currency_symbol }}' + (data.total_balance_usd || 0).toFixed(2);
document.getElementById('pendingPayments').textContent = data.pending_payments || 0; document.getElementById('pendingPayments').textContent = data.pending_payments || 0;
document.getElementById('failedPayments').textContent = data.failed_payments || 0; document.getElementById('failedPayments').textContent = data.failed_payments || 0;
} catch (error) { } catch (error) {
...@@ -1062,6 +1104,7 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -1062,6 +1104,7 @@ document.addEventListener('DOMContentLoaded', function() {
loadConfiguration(); loadConfiguration();
loadPaymentGateways(); loadPaymentGateways();
loadCryptoPrices(); loadCryptoPrices();
loadCurrencySettings();
// Auto-refresh crypto prices every 60 seconds // Auto-refresh crypto prices every 60 seconds
setInterval(loadCryptoPrices, 60000); setInterval(loadCryptoPrices, 60000);
...@@ -1139,6 +1182,41 @@ function updateCryptoPrice(crypto, priceData, currencySymbol) { ...@@ -1139,6 +1182,41 @@ function updateCryptoPrice(crypto, priceData, currencySymbol) {
} }
} }
function loadCurrencySettings() {
fetch('{{ url_for(request, "/api/admin/settings/currency") }}')
.then(response => response.json())
.then(settings => {
document.getElementById('currencyCode').value = settings.currency_code;
document.getElementById('currencySymbol').value = settings.currency_symbol;
document.getElementById('currencyDecimals').value = settings.currency_decimals;
});
}
document.getElementById('currencySettingsForm').addEventListener('submit', function(e) {
e.preventDefault();
const currencyData = {
currency_code: document.getElementById('currencyCode').value,
currency_symbol: document.getElementById('currencySymbol').value,
currency_decimals: parseInt(document.getElementById('currencyDecimals').value)
};
fetch('{{ url_for(request, "/api/admin/settings/currency") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(currencyData)
})
.then(response => response.json())
.then(result => {
showToast('Currency settings saved successfully', 'success');
})
.catch(error => {
showToast('Error saving currency settings', 'danger');
});
});
// Refresh crypto prices manually // Refresh crypto prices manually
function refreshCryptoPrices() { function refreshCryptoPrices() {
['Btc', 'Eth', 'Usdt', 'Usdc'].forEach(crypto => { ['Btc', 'Eth', 'Usdt', 'Usdc'].forEach(crypto => {
......
...@@ -69,49 +69,6 @@ ...@@ -69,49 +69,6 @@
</div> </div>
</div> </div>
<!-- Global Currency Settings -->
<div style="background: #16213e; border: 2px solid #17a2b8; border-radius: 8px; padding: 20px; margin-bottom: 20px;">
<h3 style="margin: 0 0 20px 0; color: #17a2b8;">
<i class="fas fa-dollar-sign me-2"></i>Global Currency Settings
</h3>
<form id="currencySettingsForm">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px; margin-bottom: 20px;">
<div>
<label style="display: block; margin-bottom: 5px; font-weight: 500; color: #e0e0e0;">Currency Code</label>
<select style="width: 100%; padding: 8px; border: 1px solid #0f3460; border-radius: 3px; font-size: 14px; background: #1a1a2e; color: #e0e0e0;" id="currencyCode">
<option value="USD">USD - US Dollar</option>
<option value="EUR">EUR - Euro</option>
<option value="GBP">GBP - British Pound</option>
<option value="JPY">JPY - Japanese Yen</option>
<option value="CAD">CAD - Canadian Dollar</option>
<option value="AUD">AUD - Australian Dollar</option>
<option value="CHF">CHF - Swiss Franc</option>
<option value="CNY">CNY - Chinese Yuan</option>
<option value="INR">INR - Indian Rupee</option>
<option value="BRL">BRL - Brazilian Real</option>
</select>
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: 500; color: #e0e0e0;">Currency Symbol</label>
<input type="text" style="width: 100%; padding: 8px; border: 1px solid #0f3460; border-radius: 3px; font-size: 14px; background: #1a1a2e; color: #e0e0e0;" id="currencySymbol" value="$">
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: 500; color: #e0e0e0;">Decimal Places</label>
<select style="width: 100%; padding: 8px; border: 1px solid #0f3460; border-radius: 3px; font-size: 14px; background: #1a1a2e; color: #e0e0e0;" id="currencyDecimals">
<option value="0">0</option>
<option value="1">1</option>
<option value="2" selected>2</option>
<option value="3">3</option>
</select>
</div>
</div>
<button type="submit" class="btn" style="background: #17a2b8; color: white;">
<i class="fas fa-save me-2"></i>Save Currency Settings
</button>
</form>
</div>
<!-- Create/Edit Tier Modal --> <!-- Create/Edit Tier Modal -->
<div class="modal fade" id="tierModal" tabindex="-1" aria-labelledby="tierModalLabel" aria-hidden="true" style="display: none;"> <div class="modal fade" id="tierModal" tabindex="-1" aria-labelledby="tierModalLabel" aria-hidden="true" style="display: none;">
...@@ -229,7 +186,6 @@ ...@@ -229,7 +186,6 @@
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
try { try {
loadCurrencySettings();
} catch (error) { } catch (error) {
console.error('Error initializing page:', error); console.error('Error initializing page:', error);
} }
...@@ -403,15 +359,15 @@ function closeTierModal() { ...@@ -403,15 +359,15 @@ function closeTierModal() {
document.getElementById('tierModal').style.display = 'none'; document.getElementById('tierModal').style.display = 'none';
} }
function deleteTier(tierId) { async function deleteTier(tierId) {
if (confirm('Are you sure you want to delete this tier? Users on this tier will be moved to the default free tier.')) { const ok = await showDangerConfirm('Are you sure you want to delete this tier? Users on this tier will be moved to the default free tier.', 'Delete Tier');
if (ok) {
fetch('{{ url_for(request, "/api/admin/tiers") }}/' + tierId, { fetch('{{ url_for(request, "/api/admin/tiers") }}/' + tierId, {
method: 'DELETE' method: 'DELETE'
}) })
.then(response => response.json()) .then(response => response.json())
.then(result => { .then(result => {
showToast('Tier deleted successfully', 'success'); showToast('Tier deleted successfully', 'success');
// Reload page to reflect changes
window.location.reload(); window.location.reload();
}) })
.catch(error => { .catch(error => {
...@@ -420,40 +376,6 @@ function deleteTier(tierId) { ...@@ -420,40 +376,6 @@ function deleteTier(tierId) {
} }
} }
function loadCurrencySettings() {
fetch('{{ url_for(request, "/api/admin/settings/currency") }}')
.then(response => response.json())
.then(settings => {
document.getElementById('currencyCode').value = settings.currency_code;
document.getElementById('currencySymbol').value = settings.currency_symbol;
document.getElementById('currencyDecimals').value = settings.currency_decimals;
});
}
document.getElementById('currencySettingsForm').addEventListener('submit', function(e) {
e.preventDefault();
const currencyData = {
currency_code: document.getElementById('currencyCode').value,
currency_symbol: document.getElementById('currencySymbol').value,
currency_decimals: parseInt(document.getElementById('currencyDecimals').value)
};
fetch('{{ url_for(request, "/api/admin/settings/currency") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(currencyData)
})
.then(response => response.json())
.then(result => {
showToast('Currency settings saved successfully', 'success');
})
.catch(error => {
showToast('Error saving currency settings', 'danger');
});
});
function showToast(message, type) { function showToast(message, type) {
......
...@@ -99,7 +99,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -99,7 +99,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</span> </span>
{% if date_range_usage %} {% if date_range_usage %}
<span style="margin-left: 20px; color: #a0a0a0;"> <span style="margin-left: 20px; color: #a0a0a0;">
| Total: {{ format_tokens(date_range_usage.total_tokens) }} tokens | Estimated Cost: ${{ "%.2f"|format(date_range_usage.estimated_cost) }} | Total: {{ format_tokens(date_range_usage.total_tokens) }} tokens | Estimated Cost: {{ currency_symbol }}{{ "%.2f"|format(date_range_usage.estimated_cost) }}
</span> </span>
{% endif %} {% endif %}
</div> </div>
...@@ -494,7 +494,7 @@ document.getElementById('timeRangeSelect').addEventListener('change', function() ...@@ -494,7 +494,7 @@ document.getElementById('timeRangeSelect').addEventListener('change', function()
{% if total_requests > 0 %} {% if total_requests > 0 %}
{% set weighted_sum = namespace(value=0) %} {% set weighted_sum = namespace(value=0) %}
{% for provider in provider_stats %} {% for provider in provider_stats %}
{% set weighted_sum.value = weighted_sum.value + (provider.avg_latency_ms * provider.requests.total) %} {% set weighted_sum.value = weighted_sum.value + (provider.avg_latency_ms | float * provider.requests.total) %}
{% endfor %} {% endfor %}
{% set avg_latency = weighted_sum.value / total_requests %} {% set avg_latency = weighted_sum.value / total_requests %}
{% if avg_latency > 1000 %}{{ "%.1f"|format(avg_latency / 1000) }}s{% else %}{{ "%.0f"|format(avg_latency) }}ms{% endif %} {% if avg_latency > 1000 %}{{ "%.1f"|format(avg_latency / 1000) }}s{% else %}{{ "%.0f"|format(avg_latency) }}ms{% endif %}
...@@ -521,7 +521,7 @@ document.getElementById('timeRangeSelect').addEventListener('change', function() ...@@ -521,7 +521,7 @@ document.getElementById('timeRangeSelect').addEventListener('change', function()
<h4 style="font-size: 14px; margin-bottom: 10px;"> <h4 style="font-size: 14px; margin-bottom: 10px;">
{% if from_date or to_date %}Selected Period Cost{% else %}Today's Estimated Cost{% endif %} {% if from_date or to_date %}Selected Period Cost{% else %}Today's Estimated Cost{% endif %}
</h4> </h4>
<p style="font-size: 28px; font-weight: bold;">${{ "%.2f"|format(cost_overview.total_estimated_cost_today) }}</p> <p style="font-size: 28px; font-weight: bold;">{{ currency_symbol }}{{ "%.2f"|format(cost_overview.total_estimated_cost_today) }}</p>
{% if cost_overview.date_range %} {% if cost_overview.date_range %}
<small style="color: rgba(255,255,255,0.8);"> <small style="color: rgba(255,255,255,0.8);">
{{ cost_overview.date_range.start[:10] }} to {{ cost_overview.date_range.end[:10] }} {{ cost_overview.date_range.start[:10] }} to {{ cost_overview.date_range.end[:10] }}
......
...@@ -315,14 +315,14 @@ function renderAutoselectModels(autoselectKey) { ...@@ -315,14 +315,14 @@ function renderAutoselectModels(autoselectKey) {
}); });
} }
function addAutoselect() { async function addAutoselect() {
const key = prompt('Enter autoselect key (e.g., "autoselect", "smart-select"):'); const key = await showPrompt('Enter autoselect key (e.g., "autoselect", "smart-select"):', '', 'e.g. autoselect', 'Add Autoselect');
if (!key) { if (!key) {
return; return;
} }
if (autoselectConfig[key]) { if (autoselectConfig[key]) {
alert('Autoselect key already exists'); showAlert('Autoselect key already exists', 'Duplicate Key', '⚠️', 'warn');
return; return;
} }
...@@ -338,8 +338,8 @@ function addAutoselect() { ...@@ -338,8 +338,8 @@ function addAutoselect() {
renderAutoselectList(); renderAutoselectList();
} }
function removeAutoselect(key) { async function removeAutoselect(key) {
if (confirm(`Remove autoselect "${key}"?`)) { if (await showDangerConfirm(`Remove autoselect "${key}"?`, 'Remove Autoselect')) {
delete autoselectConfig[key]; delete autoselectConfig[key];
expandedAutoselects.delete(key); expandedAutoselects.delete(key);
renderAutoselectList(); renderAutoselectList();
...@@ -375,8 +375,8 @@ function addAutoselectModel(autoselectKey) { ...@@ -375,8 +375,8 @@ function addAutoselectModel(autoselectKey) {
renderAutoselectModels(autoselectKey); renderAutoselectModels(autoselectKey);
} }
function removeAutoselectModel(autoselectKey, index) { async function removeAutoselectModel(autoselectKey, index) {
if (confirm('Remove this model?')) { if (await showDangerConfirm('Remove this model?', 'Remove Model')) {
autoselectConfig[autoselectKey].available_models.splice(index, 1); autoselectConfig[autoselectKey].available_models.splice(index, 1);
renderAutoselectModels(autoselectKey); renderAutoselectModels(autoselectKey);
} }
...@@ -399,10 +399,10 @@ async function saveAutoselect() { ...@@ -399,10 +399,10 @@ async function saveAutoselect() {
if (response.ok) { if (response.ok) {
window.location.href = autoselectData.successUrl; window.location.href = autoselectData.successUrl;
} else { } else {
alert('Error saving configuration'); showAlert('Error saving configuration', 'Error', '❌', 'danger');
} }
} catch (error) { } catch (error) {
alert('Error: ' + error.message); showAlert('Error: ' + error.message, 'Error', '❌', 'danger');
} }
} }
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 15px;"> <div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 15px;">
<div> <div>
<div style="color: #a0a0a0; font-size: 14px; margin-bottom: 5px;">Wallet Balance</div> <div style="color: #a0a0a0; font-size: 14px; margin-bottom: 5px;">Wallet Balance</div>
<div style="font-size: 32px; font-weight: bold; color: #4ade80;">${{ wallet.balance }}</div> <div style="font-size: 32px; font-weight: bold; color: #4ade80;">{{ currency_symbol }}{{ wallet.balance }}</div>
<div style="color: #a0a0a0; font-size: 13px; margin-top: 5px;"> <div style="color: #a0a0a0; font-size: 13px; margin-top: 5px;">
All subscription renewals and payments are automatically charged from your wallet first. All subscription renewals and payments are automatically charged from your wallet first.
</div> </div>
...@@ -315,11 +315,12 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -315,11 +315,12 @@ document.addEventListener('DOMContentLoaded', function() {
if (!methodId) { if (!methodId) {
console.error('No method ID found'); console.error('No method ID found');
alert('Error: Payment method ID not found'); showAlert('Error: Payment method ID not found', 'Error', '❌', 'danger');
return; return;
} }
if (confirm(`Are you sure you want to delete this ${methodType || 'payment'} method?`)) { showDangerConfirm(`Are you sure you want to delete this ${methodType || 'payment'} method?`, 'Delete Payment Method').then(ok => {
if (!ok) return;
console.log('Sending DELETE request to:', `/dashboard/billing/payment-methods/${methodId}`); console.log('Sending DELETE request to:', `/dashboard/billing/payment-methods/${methodId}`);
fetch(`/dashboard/billing/payment-methods/${methodId}`, { fetch(`/dashboard/billing/payment-methods/${methodId}`, {
...@@ -335,17 +336,16 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -335,17 +336,16 @@ document.addEventListener('DOMContentLoaded', function() {
.then(data => { .then(data => {
console.log('Response data:', data); console.log('Response data:', data);
if (data.success) { if (data.success) {
alert('Payment method deleted successfully'); showAlert('Payment method deleted successfully', 'Done', '✅', 'info').then(() => window.location.reload());
window.location.reload();
} else { } else {
alert('Error: ' + (data.error || 'Failed to delete payment method')); showAlert('Error: ' + (data.error || 'Failed to delete payment method'), 'Error', '❌', 'danger');
} }
}) })
.catch(error => { .catch(error => {
console.error('Fetch error:', error); console.error('Fetch error:', error);
alert('An error occurred while deleting the payment method: ' + error.message); showAlert('An error occurred while deleting the payment method: ' + error.message, 'Error', '❌', 'danger');
}); });
} });
}); });
}); });
...@@ -363,7 +363,7 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -363,7 +363,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (!methodId) { if (!methodId) {
console.error('No method ID found'); console.error('No method ID found');
alert('Error: Payment method ID not found'); showAlert('Error: Payment method ID not found', 'Error', '❌', 'danger');
return; return;
} }
...@@ -382,15 +382,14 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -382,15 +382,14 @@ document.addEventListener('DOMContentLoaded', function() {
.then(data => { .then(data => {
console.log('Response data:', data); console.log('Response data:', data);
if (data.success) { if (data.success) {
alert('Payment method set as default'); showAlert('Payment method set as default', 'Done', '✅', 'info').then(() => window.location.reload());
window.location.reload();
} else { } else {
alert('Error: ' + (data.error || 'Failed to set default payment method')); showAlert('Error: ' + (data.error || 'Failed to set default payment method'), 'Error', '❌', 'danger');
} }
}) })
.catch(error => { .catch(error => {
console.error('Fetch error:', error); console.error('Fetch error:', error);
alert('An error occurred while setting default payment method: ' + error.message); showAlert('An error occurred while setting default payment method: ' + error.message, 'Error', '❌', 'danger');
}); });
}); });
}); });
......
...@@ -185,13 +185,13 @@ ...@@ -185,13 +185,13 @@
</style> </style>
<script> <script>
function confirmDeletion() { async function confirmDeletion() {
const confirmation = document.getElementById('confirmation').value; const confirmation = document.getElementById('confirmation').value;
if (confirmation !== 'DELETE') { if (confirmation !== 'DELETE') {
alert('Please type "DELETE" exactly to confirm account deletion.'); showAlert('Please type "DELETE" exactly to confirm account deletion.', 'Validation Error', '⚠️', 'warn');
return false; return false;
} }
return confirm('Are you absolutely sure? This action cannot be undone and all your data will be permanently deleted.'); return await showDangerConfirm('Are you absolutely sure? This action cannot be undone and all your data will be permanently deleted.', 'Delete Account');
} }
</script> </script>
{% endblock %} {% endblock %}
...@@ -19,55 +19,94 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -19,55 +19,94 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% block title %}Overview - AISBF Dashboard{% endblock %} {% block title %}Overview - AISBF Dashboard{% endblock %}
{% block content %} {% block content %}
<h2 style="margin-bottom: 30px;">Dashboard Overview</h2> <style>
.overview-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 32px; }
.stat-card { background: #1a1a2e; border: 1px solid #0f3460; border-radius: 10px; padding: 20px; display: flex; align-items: center; gap: 16px; transition: border-color .2s; }
.stat-card:hover { border-color: #e94560; }
.stat-icon { font-size: 2em; width: 48px; height: 48px; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.stat-icon.blue { background: rgba(52,152,219,.15); }
.stat-icon.green { background: rgba(46,204,113,.15); }
.stat-icon.red { background: rgba(233,69,96,.15); }
.stat-icon.purple{ background: rgba(155,89,182,.15); }
.stat-value { font-size: 2em; font-weight: 700; line-height: 1; }
.stat-label { font-size: .85em; color: #a0a0a0; margin-top: 2px; }
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px; margin-bottom: 30px;"> .info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; margin-bottom: 32px; }
<div style="background: #3498db; color: white; padding: 20px; border-radius: 8px;"> .info-card { background: #1a1a2e; border: 1px solid #0f3460; border-radius: 10px; padding: 20px; }
<h3 style="font-size: 16px; margin-bottom: 10px;">Providers</h3> .info-card h4 { font-size: .8em; text-transform: uppercase; letter-spacing: .08em; color: #a0a0a0; margin-bottom: 14px; }
<p style="font-size: 32px; font-weight: bold;">{{ providers_count }}</p> .info-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid #0f3460; font-size: .9em; }
.info-row:last-child { border-bottom: none; }
.info-row .val { color: #e0e0e0; font-weight: 500; }
.badge { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: .8em; font-weight: 600; }
.badge-green { background: rgba(46,204,113,.15); color: #2ecc71; }
.badge-red { background: rgba(231,76,60,.15); color: #e74c3c; }
.badge-blue { background: rgba(52,152,219,.15); color: #3498db; }
.quick-actions { display: flex; flex-wrap: wrap; gap: 10px; }
</style>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:28px;">
<div>
<h2 style="margin:0; font-size:1.6em;">Dashboard Overview</h2>
<p style="color:#a0a0a0; margin:4px 0 0; font-size:.9em;">AISBF v{{ __version__ }} — Admin</p>
</div>
<a href="{{ url_for(request, '/dashboard/settings') }}" class="btn btn-secondary" style="margin:0;"><i class="fas fa-cog"></i> Settings</a>
</div>
<!-- Stats row -->
<div class="overview-grid">
<div class="stat-card">
<div class="stat-icon blue"><i class="fas fa-plug" style="color:#3498db;"></i></div>
<div>
<div class="stat-value" style="color:#3498db;">{{ providers_count }}</div>
<div class="stat-label">Providers</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon green"><i class="fas fa-sync-alt" style="color:#2ecc71;"></i></div>
<div>
<div class="stat-value" style="color:#2ecc71;">{{ rotations_count }}</div>
<div class="stat-label">Rotations</div>
</div>
</div> </div>
<div style="background: #2ecc71; color: white; padding: 20px; border-radius: 8px;"> <div class="stat-card">
<h3 style="font-size: 16px; margin-bottom: 10px;">Rotations</h3> <div class="stat-icon red"><i class="fas fa-brain" style="color:#e94560;"></i></div>
<p style="font-size: 32px; font-weight: bold;">{{ rotations_count }}</p> <div>
<div class="stat-value" style="color:#e94560;">{{ autoselect_count }}</div>
<div class="stat-label">Autoselects</div>
</div>
</div> </div>
<div style="background: #e74c3c; color: white; padding: 20px; border-radius: 8px;"> <div class="stat-card">
<h3 style="font-size: 16px; margin-bottom: 10px;">Autoselect</h3> <div class="stat-icon purple"><i class="fas fa-users" style="color:#9b59b6;"></i></div>
<p style="font-size: 32px; font-weight: bold;">{{ autoselect_count }}</p> <div>
<div class="stat-value" style="color:#9b59b6;">{{ users_count if users_count is defined else '—' }}</div>
<div class="stat-label">Users</div>
</div>
</div> </div>
</div> </div>
<h3 style="margin-bottom: 15px;">Server Information</h3> <!-- Info cards -->
<table> <div class="info-grid">
<tr> <div class="info-card">
<th>Setting</th> <h4><i class="fas fa-server"></i> &nbsp;Server</h4>
<th>Value</th> <div class="info-row"><span>Host</span><span class="val">{{ server_config.host }}</span></div>
</tr> <div class="info-row"><span>Port</span><span class="val">{{ server_config.port }}</span></div>
<tr> <div class="info-row"><span>Protocol</span><span class="val"><span class="badge {% if server_config.protocol == 'https' %}badge-green{% else %}badge-blue{% endif %}">{{ server_config.protocol|upper }}</span></span></div>
<td>Host</td> <div class="info-row"><span>Auth</span><span class="val"><span class="badge {% if server_config.auth_enabled %}badge-green{% else %}badge-red{% endif %}">{{ 'Enabled' if server_config.auth_enabled else 'Disabled' }}</span></span></div>
<td>{{ server_config.host }}</td> </div>
</tr>
<tr>
<td>Port</td>
<td>{{ server_config.port }}</td>
</tr>
<tr>
<td>Protocol</td>
<td>{{ server_config.protocol }}</td>
</tr>
<tr>
<td>Authentication</td>
<td>{{ 'Enabled' if server_config.auth_enabled else 'Disabled' }}</td>
</tr>
</table>
<h3 style="margin-top: 30px; margin-bottom: 15px;">Quick Actions</h3> <div class="info-card">
<div style="display: flex; gap: 10px; flex-wrap: wrap;"> <h4><i class="fas fa-bolt"></i> &nbsp;Quick Actions</h4>
<a href="{{ url_for(request, '/dashboard/providers') }}" class="btn">Manage Providers</a> <div class="quick-actions" style="margin-top:4px;">
<a href="{{ url_for(request, '/dashboard/rotations') }}" class="btn">Manage Rotations</a> <a href="{{ url_for(request, '/dashboard/providers') }}" class="btn" style="margin:0; font-size:.85em; padding:8px 14px;"><i class="fas fa-plug"></i> Providers</a>
<a href="{{ url_for(request, '/dashboard/autoselect') }}" class="btn">Manage Autoselect</a> <a href="{{ url_for(request, '/dashboard/rotations') }}" class="btn btn-secondary" style="margin:0; font-size:.85em; padding:8px 14px;"><i class="fas fa-sync-alt"></i> Rotations</a>
<a href="{{ url_for(request, '/dashboard/prompts') }}" class="btn">Manage Prompts</a> <a href="{{ url_for(request, '/dashboard/autoselect') }}" class="btn btn-secondary" style="margin:0; font-size:.85em; padding:8px 14px;"><i class="fas fa-brain"></i> Autoselect</a>
<a href="{{ url_for(request, '/dashboard/rate-limits') }}" class="btn">Rate Limits</a> <a href="{{ url_for(request, '/dashboard/users') }}" class="btn btn-secondary" style="margin:0; font-size:.85em; padding:8px 14px;"><i class="fas fa-users"></i> Users</a>
<a href="{{ url_for(request, '/dashboard/response-cache') }}" class="btn">Response Cache</a> <a href="{{ url_for(request, '/dashboard/analytics') }}" class="btn btn-secondary" style="margin:0; font-size:.85em; padding:8px 14px;"><i class="fas fa-chart-bar"></i> Analytics</a>
<a href="{{ url_for(request, '/dashboard/settings') }}" class="btn btn-secondary">Server Settings</a> <a href="{{ url_for(request, '/dashboard/rate-limits') }}" class="btn btn-secondary" style="margin:0; font-size:.85em; padding:8px 14px;"><i class="fas fa-tachometer-alt"></i> Rate Limits</a>
<a href="{{ url_for(request, '/dashboard/response-cache') }}" class="btn btn-secondary" style="margin:0; font-size:.85em; padding:8px 14px;"><i class="fas fa-database"></i> Cache</a>
<a href="{{ url_for(request, '/dashboard/prompts') }}" class="btn btn-secondary" style="margin:0; font-size:.85em; padding:8px 14px;"><i class="fas fa-comment-dots"></i> Prompts</a>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
...@@ -143,7 +143,7 @@ function openOAuthPopup(url) { ...@@ -143,7 +143,7 @@ function openOAuthPopup(url) {
window.removeEventListener('message', messageListener); window.removeEventListener('message', messageListener);
// Show error // Show error
alert(event.data.error || 'Authentication failed'); showAlert(event.data.error || 'Authentication failed', 'Authentication Error', '❌', 'danger');
} }
}; };
......
...@@ -216,8 +216,9 @@ ...@@ -216,8 +216,9 @@
{% block extra_js %} {% block extra_js %}
<script> <script>
function subscribeToTier(tierId) { async function subscribeToTier(tierId) {
if (confirm('Are you sure you want to upgrade to this tier?')) { const ok = await showConfirm('Are you sure you want to upgrade to this tier?', 'Upgrade Plan');
if (ok) {
fetch(url_for(`/api/subscribe/${tierId}`), { fetch(url_for(`/api/subscribe/${tierId}`), {
method: 'POST', method: 'POST',
headers: { headers: {
...@@ -241,8 +242,9 @@ function subscribeToTier(tierId) { ...@@ -241,8 +242,9 @@ function subscribeToTier(tierId) {
} }
} }
function downgradeToFree() { async function downgradeToFree() {
if (confirm('Are you sure you want to downgrade to the free tier? Your current subscription will remain active until the end of the billing period.')) { const ok = await showConfirm('Are you sure you want to downgrade to the free tier? Your current subscription will remain active until the end of the billing period.', 'Downgrade Plan');
if (ok) {
fetch(url_for('/api/subscribe/free'), { fetch(url_for('/api/subscribe/free'), {
method: 'POST', method: 'POST',
headers: { headers: {
......
...@@ -72,9 +72,10 @@ function switchPrompt() { ...@@ -72,9 +72,10 @@ function switchPrompt() {
} }
} }
function resetPrompt() { async function resetPrompt() {
if (!currentPromptKey) return; if (!currentPromptKey) return;
if (confirm('Are you sure you want to reset this prompt to the default admin configuration?')) { const ok = await showDangerConfirm('Are you sure you want to reset this prompt to the default admin configuration?', 'Reset Prompt');
if (ok) {
fetch('/dashboard/prompts/reset/' + currentPromptKey, { fetch('/dashboard/prompts/reset/' + currentPromptKey, {
method: 'POST', method: 'POST',
headers: { headers: {
......
...@@ -134,7 +134,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -134,7 +134,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% block extra_js %} {% block extra_js %}
<script> <script>
let providersData = {{ providers_json | safe }}; let providersData = {{ providers_json | replace("</script>", "<\\/script>") | safe }};
let expandedProviders = new Set(); let expandedProviders = new Set();
// Chunk size: 512KB chunks for maximum compatibility with restrictive proxies // Chunk size: 512KB chunks for maximum compatibility with restrictive proxies
...@@ -1603,8 +1603,8 @@ async function pollKiloAuth(providerKey, deviceCode) { ...@@ -1603,8 +1603,8 @@ async function pollKiloAuth(providerKey, deviceCode) {
// This function is no longer needed as polling is handled in authenticateKilo // This function is no longer needed as polling is handled in authenticateKilo
} }
function removeProvider(key) { async function removeProvider(key) {
if (confirm(`Remove provider "${key}"?`)) { if (await showDangerConfirm(`Remove provider "${key}"?`, 'Remove Provider')) {
delete providersData[key]; delete providersData[key];
expandedProviders.delete(key); expandedProviders.delete(key);
renderProvidersList(); renderProvidersList();
...@@ -1684,8 +1684,8 @@ function addModel(key) { ...@@ -1684,8 +1684,8 @@ function addModel(key) {
renderModels(key); renderModels(key);
} }
function removeModel(providerKey, index) { async function removeModel(providerKey, index) {
if (confirm('Remove this model?')) { if (await showDangerConfirm('Remove this model?', 'Remove Model')) {
providersData[providerKey].models.splice(index, 1); providersData[providerKey].models.splice(index, 1);
renderModels(providerKey); renderModels(providerKey);
} }
...@@ -1696,12 +1696,12 @@ async function confirmAddProvider() { ...@@ -1696,12 +1696,12 @@ async function confirmAddProvider() {
const type = document.getElementById('new-provider-type').value; const type = document.getElementById('new-provider-type').value;
if (!key) { if (!key) {
alert('Please enter a provider key'); showAlert('Please enter a provider key', 'Missing Key', '⚠️', 'warn');
return; return;
} }
if (providersData[key]) { if (providersData[key]) {
alert('Provider key already exists'); showAlert('Provider key already exists', 'Duplicate Key', '⚠️', 'warn');
return; return;
} }
...@@ -1836,10 +1836,10 @@ async function saveProviders() { ...@@ -1836,10 +1836,10 @@ async function saveProviders() {
if (response.ok) { if (response.ok) {
window.location.href = '/dashboard/providers?success=1'; window.location.href = '/dashboard/providers?success=1';
} else { } else {
alert('Error saving configuration'); showAlert('Error saving configuration', 'Error', '❌', 'danger');
} }
} catch (error) { } catch (error) {
alert('Error: ' + error.message); showAlert('Error: ' + error.message, 'Error', '❌', 'danger');
} }
} }
......
...@@ -169,9 +169,8 @@ async function loadRateLimits() { ...@@ -169,9 +169,8 @@ async function loadRateLimits() {
} }
async function resetRateLimiter(providerId) { async function resetRateLimiter(providerId) {
if (!confirm(`Reset rate limiter for ${providerId}?`)) { const ok = await showConfirm(`Reset rate limiter for ${providerId}?`, 'Reset Rate Limiter');
return; if (!ok) return;
}
try { try {
const response = await fetch(`/dashboard/rate-limits/${providerId}/reset`, { const response = await fetch(`/dashboard/rate-limits/${providerId}/reset`, {
...@@ -180,20 +179,19 @@ async function resetRateLimiter(providerId) { ...@@ -180,20 +179,19 @@ async function resetRateLimiter(providerId) {
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
alert(data.message); showAlert(data.message, 'Done', '✅', 'info');
loadRateLimits(); loadRateLimits();
} else { } else {
alert('Error: ' + data.error); showAlert('Error: ' + data.error, 'Error', '❌', 'danger');
} }
} catch (error) { } catch (error) {
alert('Error: ' + error.message); showAlert('Error: ' + error.message, 'Error', '❌', 'danger');
} }
} }
async function clearAllRateLimiters() { async function clearAllRateLimiters() {
if (!confirm('Reset all rate limiters? This will clear all learned rate limits.')) { const ok = await showDangerConfirm('Reset all rate limiters? This will clear all learned rate limits.', 'Reset All');
return; if (!ok) return;
}
const content = document.getElementById('rate-limits-content'); const content = document.getElementById('rate-limits-content');
const providerIds = []; const providerIds = [];
...@@ -213,10 +211,10 @@ async function clearAllRateLimiters() { ...@@ -213,10 +211,10 @@ async function clearAllRateLimiters() {
} }
} }
alert('All rate limiters reset successfully'); showAlert('All rate limiters reset successfully', 'Done', '✅', 'info');
loadRateLimits(); loadRateLimits();
} catch (error) { } catch (error) {
alert('Error: ' + error.message); showAlert('Error: ' + error.message, 'Error', '❌', 'danger');
} }
} }
......
...@@ -122,29 +122,24 @@ function refreshStats() { ...@@ -122,29 +122,24 @@ function refreshStats() {
location.reload(); location.reload();
} }
function clearCache() { async function clearCache() {
if (!confirm('Are you sure you want to clear the entire response cache? This action cannot be undone.')) { const ok = await showDangerConfirm('Are you sure you want to clear the entire response cache? This action cannot be undone.', 'Clear Cache');
return; if (!ok) return;
}
fetch('{{ url_for(request, "/dashboard/response-cache/clear") }}', { try {
method: 'POST', const response = await fetch('{{ url_for(request, "/dashboard/response-cache/clear") }}', {
headers: { method: 'POST',
'Content-Type': 'application/json' headers: {'Content-Type': 'application/json'}
} });
}) const data = await response.json();
.then(response => response.json())
.then(data => {
if (data.success) { if (data.success) {
alert('Cache cleared successfully!'); showAlert('Cache cleared successfully!', 'Done', '✅', 'info').then(() => location.reload());
location.reload();
} else { } else {
alert('Error clearing cache: ' + (data.error || 'Unknown error')); showAlert('Error clearing cache: ' + (data.error || 'Unknown error'), 'Error', '❌', 'danger');
} }
}) } catch (error) {
.catch(error => { showAlert('Error clearing cache: ' + error, 'Error', '❌', 'danger');
alert('Error clearing cache: ' + error); }
});
} }
</script> </script>
......
...@@ -268,10 +268,11 @@ function updateGlobalNotify(value) { ...@@ -268,10 +268,11 @@ function updateGlobalNotify(value) {
rotationsConfig.notifyerrors = value; rotationsConfig.notifyerrors = value;
} }
function addRotation() { async function addRotation() {
const key = prompt('Enter rotation key (e.g., "coding", "general"):'); const key = await showPrompt('Enter rotation key (e.g., "coding", "general"):', '', 'rotation-key', 'Add Rotation');
if (!key || rotationsConfig.rotations[key]) { if (!key) return;
alert('Invalid or duplicate rotation key'); if (rotationsConfig.rotations[key]) {
showAlert('Rotation key already exists', 'Duplicate Key', '⚠️', 'warn');
return; return;
} }
...@@ -290,8 +291,8 @@ function addRotation() { ...@@ -290,8 +291,8 @@ function addRotation() {
renderRotationsList(); renderRotationsList();
} }
function removeRotation(key) { async function removeRotation(key) {
if (confirm(`Remove rotation "${key}"?`)) { if (await showDangerConfirm(`Remove rotation "${key}"?`, 'Remove Rotation')) {
delete rotationsConfig.rotations[key]; delete rotationsConfig.rotations[key];
expandedRotations.delete(key); expandedRotations.delete(key);
renderRotationsList(); renderRotationsList();
...@@ -327,8 +328,8 @@ function addRotationProvider(rotationKey) { ...@@ -327,8 +328,8 @@ function addRotationProvider(rotationKey) {
renderRotationProviders(rotationKey); renderRotationProviders(rotationKey);
} }
function removeRotationProvider(rotationKey, providerIndex) { async function removeRotationProvider(rotationKey, providerIndex) {
if (confirm('Remove this provider?')) { if (await showDangerConfirm('Remove this provider?', 'Remove Provider')) {
rotationsConfig.rotations[rotationKey].providers.splice(providerIndex, 1); rotationsConfig.rotations[rotationKey].providers.splice(providerIndex, 1);
renderRotationProviders(rotationKey); renderRotationProviders(rotationKey);
} }
...@@ -353,8 +354,8 @@ function addRotationModel(rotationKey, providerIndex) { ...@@ -353,8 +354,8 @@ function addRotationModel(rotationKey, providerIndex) {
renderRotationModels(rotationKey, providerIndex); renderRotationModels(rotationKey, providerIndex);
} }
function removeRotationModel(rotationKey, providerIndex, modelIndex) { async function removeRotationModel(rotationKey, providerIndex, modelIndex) {
if (confirm('Remove this model?')) { if (await showDangerConfirm('Remove this model?', 'Remove Model')) {
rotationsConfig.rotations[rotationKey].providers[providerIndex].models.splice(modelIndex, 1); rotationsConfig.rotations[rotationKey].providers[providerIndex].models.splice(modelIndex, 1);
renderRotationModels(rotationKey, providerIndex); renderRotationModels(rotationKey, providerIndex);
} }
...@@ -393,10 +394,10 @@ async function saveRotations() { ...@@ -393,10 +394,10 @@ async function saveRotations() {
if (response.ok) { if (response.ok) {
window.location.href = rotationsData.successUrl; window.location.href = rotationsData.successUrl;
} else { } else {
alert('Error saving configuration'); showAlert('Error saving configuration', 'Error', '❌', 'danger');
} }
} catch (error) { } catch (error) {
alert('Error: ' + error.message); showAlert('Error: ' + error.message, 'Error', '❌', 'danger');
} }
} }
......
...@@ -19,7 +19,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -19,7 +19,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% block title %}Settings - AISBF Dashboard{% endblock %} {% block title %}Settings - AISBF Dashboard{% endblock %}
{% block content %} {% block content %}
<h2 style="margin-bottom: 30px;">Server Settings</h2> <style>
.settings-tabs { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 28px; border-bottom: 2px solid #0f3460; padding-bottom: 0; }
.settings-tab { padding: 10px 18px; cursor: pointer; border-radius: 6px 6px 0 0; color: #a0a0a0; font-size: .9em; border: 1px solid transparent; border-bottom: none; margin-bottom: -2px; transition: background .15s, color .15s; }
.settings-tab:hover { background: #0f3460; color: #e0e0e0; }
.settings-tab.active { background: #16213e; color: #e94560; border-color: #0f3460; border-bottom-color: #16213e; font-weight: 600; }
.settings-section { display: none; }
.settings-section.active { display: block; }
.section-title { font-size: 1.1em; font-weight: 600; color: #e94560; margin: 0 0 20px; padding-bottom: 8px; border-bottom: 1px solid #0f3460; }
</style>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px;">
<h2 style="margin:0;">Server Settings</h2>
</div>
{% if success %} {% if success %}
<div class="alert alert-success">{{ success }}</div> <div class="alert alert-success">{{ success }}</div>
...@@ -29,8 +41,26 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -29,8 +41,26 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div class="alert alert-error">{{ error }}</div> <div class="alert alert-error">{{ error }}</div>
{% endif %} {% endif %}
<div class="settings-tabs">
<div class="settings-tab active" onclick="switchTab('server')"><i class="fas fa-server"></i> Server</div>
<div class="settings-tab" onclick="switchTab('auth')"><i class="fas fa-key"></i> Auth &amp; MCP</div>
<div class="settings-tab" onclick="switchTab('dashboard')"><i class="fas fa-tachometer-alt"></i> Dashboard</div>
<div class="settings-tab" onclick="switchTab('models')"><i class="fas fa-brain"></i> Models</div>
<div class="settings-tab" onclick="switchTab('database')"><i class="fas fa-database"></i> Database</div>
<div class="settings-tab" onclick="switchTab('cache')"><i class="fas fa-bolt"></i> Cache</div>
<div class="settings-tab" onclick="switchTab('classification')"><i class="fas fa-filter"></i> Classification</div>
<div class="settings-tab" onclick="switchTab('tor')"><i class="fas fa-user-secret"></i> TOR</div>
<div class="settings-tab" onclick="switchTab('signup')"><i class="fas fa-user-plus"></i> Signup</div>
<div class="settings-tab" onclick="switchTab('oauth2')"><i class="fab fa-openid"></i> OAuth2</div>
<div class="settings-tab" onclick="switchTab('smtp')"><i class="fas fa-envelope"></i> SMTP</div>
<div class="settings-tab" onclick="switchTab('batching')"><i class="fas fa-layer-group"></i> Batching</div>
<div class="settings-tab" onclick="switchTab('ratelimit')"><i class="fas fa-tachometer-alt"></i> Rate Limiting</div>
<div class="settings-tab" onclick="switchTab('admin')"><i class="fas fa-shield-alt"></i> Admin</div>
</div>
<form method="POST"> <form method="POST">
<h3 style="margin-bottom: 20px;">Server Configuration</h3> <div class="settings-section active" id="tab-server">
<div class="section-title"><i class="fas fa-server"></i> Server Configuration</div>
<div class="form-group"> <div class="form-group">
<label for="host">Host</label> <label for="host">Host</label>
...@@ -69,8 +99,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -69,8 +99,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<small style="color: #666; display: block; margin-top: 5px;">Path to SSL private key file. Leave empty to use default (~/.aisbf/key.pem). Auto-generated with certificate.</small> <small style="color: #666; display: block; margin-top: 5px;">Path to SSL private key file. Leave empty to use default (~/.aisbf/key.pem). Auto-generated with certificate.</small>
</div> </div>
</div> </div>
</div><!-- /tab-server -->
<h3 style="margin: 30px 0 20px;">Authentication</h3> <div class="settings-section" id="tab-auth">
<div class="section-title"><i class="fas fa-key"></i> Authentication</div>
<div class="form-group"> <div class="form-group">
<label> <label>
...@@ -84,7 +116,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -84,7 +116,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<textarea id="auth_tokens" name="auth_tokens" style="min-height: 100px;">{{ '\n'.join(config.auth.tokens) }}</textarea> <textarea id="auth_tokens" name="auth_tokens" style="min-height: 100px;">{{ '\n'.join(config.auth.tokens) }}</textarea>
</div> </div>
<h3 style="margin: 30px 0 20px;">MCP Server</h3> <div class="section-title" style="margin-top:28px;"><i class="fas fa-network-wired"></i> MCP Server</div>
<div class="form-group"> <div class="form-group">
<label> <label>
...@@ -105,8 +137,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -105,8 +137,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<textarea id="fullconfig_tokens" name="fullconfig_tokens" style="min-height: 80px;" placeholder="Tokens for full configuration access">{{ '\n'.join(config.mcp.fullconfig_tokens) if config.mcp.fullconfig_tokens else '' }}</textarea> <textarea id="fullconfig_tokens" name="fullconfig_tokens" style="min-height: 80px;" placeholder="Tokens for full configuration access">{{ '\n'.join(config.mcp.fullconfig_tokens) if config.mcp.fullconfig_tokens else '' }}</textarea>
<small style="color: #666; display: block; margin-top: 5px;">Tokens that give access to full system configuration + standard APIs</small> <small style="color: #666; display: block; margin-top: 5px;">Tokens that give access to full system configuration + standard APIs</small>
</div> </div>
</div><!-- /tab-auth -->
<h3 style="margin: 30px 0 20px;">Dashboard</h3> <div class="settings-section" id="tab-dashboard">
<div class="section-title"><i class="fas fa-tachometer-alt"></i> Dashboard</div>
<div class="form-group"> <div class="form-group">
<label for="dashboard_username">Dashboard Username</label> <label for="dashboard_username">Dashboard Username</label>
...@@ -117,8 +151,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -117,8 +151,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<label for="dashboard_password">Dashboard Password</label> <label for="dashboard_password">Dashboard Password</label>
<input type="password" id="dashboard_password" name="dashboard_password" placeholder="Leave blank to keep current"> <input type="password" id="dashboard_password" name="dashboard_password" placeholder="Leave blank to keep current">
</div> </div>
</div><!-- /tab-dashboard -->
<h3 style="margin: 30px 0 20px;">Internal Models</h3> <div class="settings-section" id="tab-models">
<div class="section-title"><i class="fas fa-brain"></i> Internal Models</div>
<div class="form-group"> <div class="form-group">
<label for="condensation_model_id">Condensation Model ID</label> <label for="condensation_model_id">Condensation Model ID</label>
...@@ -149,8 +185,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -149,8 +185,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<input type="text" id="semantic_vectorization" name="semantic_vectorization" value="{{ config.internal_model.semantic_vectorization or 'sentence-transformers/all-MiniLM-L6-v2' }}" required> <input type="text" id="semantic_vectorization" name="semantic_vectorization" value="{{ config.internal_model.semantic_vectorization or 'sentence-transformers/all-MiniLM-L6-v2' }}" required>
<small style="color: #666; display: block; margin-top: 5px;">Model used for semantic embedding and vectorization</small> <small style="color: #666; display: block; margin-top: 5px;">Model used for semantic embedding and vectorization</small>
</div> </div>
</div><!-- /tab-models -->
<h3 style="margin: 30px 0 20px;">Database Configuration</h3>
<div class="settings-section" id="tab-database">
<div class="section-title"><i class="fas fa-database"></i> Database Configuration</div>
<div class="form-group"> <div class="form-group">
<label for="database_type">Database Type</label> <label for="database_type">Database Type</label>
...@@ -200,8 +238,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -200,8 +238,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<small style="color: #666; display: block; margin-top: 5px;">MySQL database name</small> <small style="color: #666; display: block; margin-top: 5px;">MySQL database name</small>
</div> </div>
</div> </div>
</div><!-- /tab-database -->
<h3 style="margin: 30px 0 20px;">Cache Configuration</h3> <div class="settings-section" id="tab-cache">
<div class="section-title"><i class="fas fa-bolt"></i> Cache Configuration</div>
<div class="form-group"> <div class="form-group">
<label for="cache_type">Cache Type</label> <label for="cache_type">Cache Type</label>
...@@ -287,7 +327,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -287,7 +327,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div> </div>
</div> </div>
<h3 style="margin: 30px 0 20px;">Response Cache Configuration</h3> <h3 style="margin: 24px 0 16px; font-size:1em; color:#a0a0a0; border-bottom: 1px solid #0f3460; padding-bottom:8px;">Response Cache Configuration</h3>
<div class="form-group"> <div class="form-group">
<label> <label>
...@@ -381,7 +421,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -381,7 +421,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div> </div>
</div> </div>
<h3 style="margin: 30px 0 20px;">Response Cache Statistics</h3> <h3 style="margin: 24px 0 16px; font-size:1em; color:#a0a0a0; border-bottom: 1px solid #0f3460; padding-bottom:8px;">Response Cache</h3>
<div id="cache-stats" style="margin-bottom: 20px; padding: 15px; background: #0f3460; border-radius: 6px; border-left: 4px solid #16213e;"> <div id="cache-stats" style="margin-bottom: 20px; padding: 15px; background: #0f3460; border-radius: 6px; border-left: 4px solid #16213e;">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;"> <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
...@@ -422,7 +462,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -422,7 +462,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div> </div>
</div> </div>
<h3 style="margin: 30px 0 20px;">Content Classification</h3> </div><!-- /tab-cache -->
<div class="settings-section" id="tab-classification">
<div class="section-title"><i class="fas fa-filter"></i> Content Classification</div>
<div class="form-group"> <div class="form-group">
<label> <label>
...@@ -447,8 +490,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -447,8 +490,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</label> </label>
<small style="color: #666; display: block; margin-top: 5px;">Enable semantic content classification using the configured model</small> <small style="color: #666; display: block; margin-top: 5px;">Enable semantic content classification using the configured model</small>
</div> </div>
</div><!-- /tab-classification -->
<h3 style="margin: 30px 0 20px;">TOR Hidden Service</h3>
<div class="settings-section" id="tab-tor">
<div class="section-title"><i class="fas fa-user-secret"></i> TOR Hidden Service</div>
<div id="tor-status" style="margin-bottom: 20px; padding: 15px; background: #0f3460; border-radius: 6px; border-left: 4px solid #16213e;"> <div id="tor-status" style="margin-bottom: 20px; padding: 15px; background: #0f3460; border-radius: 6px; border-left: 4px solid #16213e;">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;"> <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
...@@ -562,8 +607,10 @@ brew services restart tor # macOS</code></pre> ...@@ -562,8 +607,10 @@ brew services restart tor # macOS</code></pre>
<small style="color: #666; display: block; margin-top: 5px;">TOR SOCKS proxy port (default: 9050)</small> <small style="color: #666; display: block; margin-top: 5px;">TOR SOCKS proxy port (default: 9050)</small>
</div> </div>
</div> </div>
</div><!-- /tab-tor -->
<h3 style="margin: 30px 0 20px;">User Signup</h3>
<div class="settings-section" id="tab-signup">
<div class="section-title"><i class="fas fa-user-plus"></i> User Signup</div>
<div class="form-group"> <div class="form-group">
<label> <label>
...@@ -588,10 +635,12 @@ brew services restart tor # macOS</code></pre> ...@@ -588,10 +635,12 @@ brew services restart tor # macOS</code></pre>
min="1" max="168"> min="1" max="168">
<small style="color: #666; display: block; margin-top: 5px;">How long verification links remain valid (1-168 hours)</small> <small style="color: #666; display: block; margin-top: 5px;">How long verification links remain valid (1-168 hours)</small>
</div> </div>
</div><!-- /tab-signup -->
<h3 style="margin: 30px 0 20px;">OAuth2 Authentication</h3>
<h4 style="margin: 25px 0 15px;">Google OAuth2</h4> <div class="settings-section" id="tab-oauth2">
<div class="section-title"><i class="fab fa-openid"></i> OAuth2 Authentication</div>
<div class="section-title" style="font-size:.95em; margin-top:0;"><i class="fab fa-google"></i> Google OAuth2</div>
<div class="form-group"> <div class="form-group">
<label> <label>
...@@ -610,7 +659,7 @@ brew services restart tor # macOS</code></pre> ...@@ -610,7 +659,7 @@ brew services restart tor # macOS</code></pre>
<input type="password" id="oauth2_google_client_secret" name="oauth2_google_client_secret" value="{{ config.oauth2.google.client_secret if config.oauth2 and config.oauth2.google else '' }}" placeholder="Client Secret from Google Cloud Console"> <input type="password" id="oauth2_google_client_secret" name="oauth2_google_client_secret" value="{{ config.oauth2.google.client_secret if config.oauth2 and config.oauth2.google else '' }}" placeholder="Client Secret from Google Cloud Console">
</div> </div>
<h4 style="margin: 25px 0 15px;">GitHub OAuth2</h4> <h4 style="margin: 25px 0 15px; color:#a0a0a0;">GitHub OAuth2</h4>
<div class="form-group"> <div class="form-group">
<label> <label>
...@@ -628,8 +677,10 @@ brew services restart tor # macOS</code></pre> ...@@ -628,8 +677,10 @@ brew services restart tor # macOS</code></pre>
<label for="oauth2_github_client_secret">GitHub Client Secret</label> <label for="oauth2_github_client_secret">GitHub Client Secret</label>
<input type="password" id="oauth2_github_client_secret" name="oauth2_github_client_secret" value="{{ config.oauth2.github.client_secret if config.oauth2 and config.oauth2.github else '' }}" placeholder="Client Secret from GitHub Developer Settings"> <input type="password" id="oauth2_github_client_secret" name="oauth2_github_client_secret" value="{{ config.oauth2.github.client_secret if config.oauth2 and config.oauth2.github else '' }}" placeholder="Client Secret from GitHub Developer Settings">
</div> </div>
</div><!-- /tab-oauth2 -->
<h3 style="margin: 30px 0 20px;">SMTP Email Configuration</h3> <div class="settings-section" id="tab-smtp">
<div class="section-title"><i class="fas fa-envelope"></i> SMTP Email Configuration</div>
<div class="form-group"> <div class="form-group">
<label> <label>
...@@ -700,8 +751,10 @@ brew services restart tor # macOS</code></pre> ...@@ -700,8 +751,10 @@ brew services restart tor # macOS</code></pre>
<input type="email" id="smtp_test_email" name="smtp_test_email" placeholder="Email address to send test to" style="flex: 1;"> <input type="email" id="smtp_test_email" name="smtp_test_email" placeholder="Email address to send test to" style="flex: 1;">
<button type="button" onclick="testSMTP()" class="btn btn-secondary">Send Test Email</button> <button type="button" onclick="testSMTP()" class="btn btn-secondary">Send Test Email</button>
</div> </div>
</div><!-- /tab-smtp -->
<h3 style="margin: 30px 0 20px;">Request Batching</h3>
<div class="settings-section" id="tab-batching">
<div class="section-title"><i class="fas fa-layer-group"></i> Request Batching</div>
<div class="form-group"> <div class="form-group">
<label> <label>
...@@ -748,8 +801,10 @@ brew services restart tor # macOS</code></pre> ...@@ -748,8 +801,10 @@ brew services restart tor # macOS</code></pre>
<label for="batching_anthropic_max_batch_size">Anthropic Max Batch Size</label> <label for="batching_anthropic_max_batch_size">Anthropic Max Batch Size</label>
<input type="number" id="batching_anthropic_max_batch_size" name="batching_anthropic_max_batch_size" value="{{ config.batching.provider_settings.anthropic.max_batch_size if config.batching and config.batching.provider_settings and config.batching.provider_settings.anthropic else 5 }}" min="1"> <input type="number" id="batching_anthropic_max_batch_size" name="batching_anthropic_max_batch_size" value="{{ config.batching.provider_settings.anthropic.max_batch_size if config.batching and config.batching.provider_settings and config.batching.provider_settings.anthropic else 5 }}" min="1">
</div> </div>
</div><!-- /tab-batching -->
<h3 style="margin: 30px 0 20px;">Adaptive Rate Limiting</h3>
<div class="settings-section" id="tab-ratelimit">
<div class="section-title"><i class="fas fa-tachometer-alt"></i> Adaptive Rate Limiting</div>
<div class="form-group"> <div class="form-group">
<label> <label>
...@@ -818,8 +873,10 @@ brew services restart tor # macOS</code></pre> ...@@ -818,8 +873,10 @@ brew services restart tor # macOS</code></pre>
<input type="number" id="adaptive_consecutive_successes" name="adaptive_consecutive_successes" value="{{ config.adaptive_rate_limiting.consecutive_successes_for_recovery if config.adaptive_rate_limiting else 10 }}" min="1"> <input type="number" id="adaptive_consecutive_successes" name="adaptive_consecutive_successes" value="{{ config.adaptive_rate_limiting.consecutive_successes_for_recovery if config.adaptive_rate_limiting else 10 }}" min="1">
<small style="color: #666; display: block; margin-top: 5px;">Number of successful requests before increasing rate limit (default: 10)</small> <small style="color: #666; display: block; margin-top: 5px;">Number of successful requests before increasing rate limit (default: 10)</small>
</div> </div>
</div><!-- /tab-ratelimit -->
<h3 style="margin: 30px 0 20px;">Admin Password</h3>
<div class="settings-section" id="tab-admin">
<div class="section-title"><i class="fas fa-shield-alt"></i> Admin Password</div>
<div class="form-group"> <div class="form-group">
<label for="new_admin_password">New Admin Password</label> <label for="new_admin_password">New Admin Password</label>
...@@ -832,8 +889,9 @@ brew services restart tor # macOS</code></pre> ...@@ -832,8 +889,9 @@ brew services restart tor # macOS</code></pre>
<input type="password" id="confirm_admin_password" name="confirm_admin_password" placeholder="Confirm new password"> <input type="password" id="confirm_admin_password" name="confirm_admin_password" placeholder="Confirm new password">
<small style="color: #666; display: block; margin-top: 5px;">Re-enter the new password to confirm</small> <small style="color: #666; display: block; margin-top: 5px;">Re-enter the new password to confirm</small>
</div> </div>
</div><!-- /tab-admin -->
<div style="display: flex; gap: 10px; margin-top: 30px;">
<div style="display: flex; gap: 10px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #0f3460;">
<button type="submit" class="btn">Save Settings</button> <button type="submit" class="btn">Save Settings</button>
<a href="{{ url_for(request, '/dashboard') }}" class="btn btn-secondary">Cancel</a> <a href="{{ url_for(request, '/dashboard') }}" class="btn btn-secondary">Cancel</a>
<button type="button" onclick="testSMTP()" class="btn btn-secondary" style="margin-left: auto;">Test SMTP</button> <button type="button" onclick="testSMTP()" class="btn btn-secondary" style="margin-left: auto;">Test SMTP</button>
...@@ -841,6 +899,13 @@ brew services restart tor # macOS</code></pre> ...@@ -841,6 +899,13 @@ brew services restart tor # macOS</code></pre>
</form> </form>
<script> <script>
function switchTab(name) {
document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.settings-section').forEach(s => s.classList.remove('active'));
document.querySelector(`[onclick="switchTab('${name}')"]`).classList.add('active');
document.getElementById('tab-' + name).classList.add('active');
}
async function testSMTP() { async function testSMTP() {
const host = document.getElementById('smtp_host').value; const host = document.getElementById('smtp_host').value;
const port = document.getElementById('smtp_port').value; const port = document.getElementById('smtp_port').value;
...@@ -849,18 +914,17 @@ async function testSMTP() { ...@@ -849,18 +914,17 @@ async function testSMTP() {
const testEmail = document.getElementById('smtp_test_email').value; const testEmail = document.getElementById('smtp_test_email').value;
if (!host || !port || !fromEmail) { if (!host || !port || !fromEmail) {
alert('Please fill in SMTP host, port, and from email before testing'); showAlert('Please fill in SMTP host, port, and from email before testing', 'Missing Fields', '⚠️', 'warn');
return; return;
} }
if (!testEmail) { if (!testEmail) {
alert('Please enter a test recipient email address'); showAlert('Please enter a test recipient email address', 'Missing Fields', '⚠️', 'warn');
return; return;
} }
if (!confirm(`This will send a test email to ${testEmail}. Continue?`)) { const ok = await showConfirm(`This will send a test email to ${testEmail}. Continue?`, 'Send Test Email');
return; if (!ok) return;
}
try { try {
const response = await fetch('{{ url_for(request, "/dashboard/test-smtp") }}', { const response = await fetch('{{ url_for(request, "/dashboard/test-smtp") }}', {
...@@ -882,12 +946,12 @@ async function testSMTP() { ...@@ -882,12 +946,12 @@ async function testSMTP() {
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
alert('Test email sent successfully! Check your inbox.'); showAlert('Test email sent successfully! Check your inbox.', 'Success', '✅', 'info');
} else { } else {
alert('Failed to send test email: ' + (data.error || 'Unknown error')); showAlert('Failed to send test email: ' + (data.error || 'Unknown error'), 'Error', '❌', 'danger');
} }
} catch (error) { } catch (error) {
alert('Error testing SMTP: ' + error.message); showAlert('Error testing SMTP: ' + error.message, 'Error', '❌', 'danger');
} }
} }
</script> </script>
...@@ -973,24 +1037,25 @@ function toggleResponseCacheFields() { ...@@ -973,24 +1037,25 @@ function toggleResponseCacheFields() {
function createPersistentService() { function createPersistentService() {
const dirInput = document.getElementById('tor_hidden_service_dir'); const dirInput = document.getElementById('tor_hidden_service_dir');
// Use absolute path instead of ~ since torrc doesn't expand it
const defaultDir = '/home/{{ os.environ.get("USER", "yourusername") }}/.aisbf/tor_hidden_service'; const defaultDir = '/home/{{ os.environ.get("USER", "yourusername") }}/.aisbf/tor_hidden_service';
if (dirInput.value && dirInput.value.trim() !== '') { if (dirInput.value && dirInput.value.trim() !== '') {
if (!confirm('This will replace the current directory path. Continue?')) { showConfirm('This will replace the current directory path. Continue?', 'Replace Path').then(ok => {
return; if (!ok) return;
} dirInput.value = defaultDir;
dirInput.focus();
updateTorrcExample();
dirInput.style.backgroundColor = '#1a4d2e';
setTimeout(() => { dirInput.style.backgroundColor = ''; }, 500);
});
return;
} }
dirInput.value = defaultDir; dirInput.value = defaultDir;
dirInput.focus(); dirInput.focus();
updateTorrcExample(); updateTorrcExample();
// Visual feedback
dirInput.style.backgroundColor = '#1a4d2e'; dirInput.style.backgroundColor = '#1a4d2e';
setTimeout(() => { setTimeout(() => { dirInput.style.backgroundColor = ''; }, 500);
dirInput.style.backgroundColor = '';
}, 500);
} }
function toggleTorrcExample(type) { function toggleTorrcExample(type) {
...@@ -1103,9 +1168,8 @@ async function refreshCacheStats() { ...@@ -1103,9 +1168,8 @@ async function refreshCacheStats() {
} }
async function clearResponseCache() { async function clearResponseCache() {
if (!confirm('Are you sure you want to clear the response cache? This will remove all cached responses.')) { const ok = await showDangerConfirm('Are you sure you want to clear the response cache? This will remove all cached responses.', 'Clear Cache');
return; if (!ok) return;
}
try { try {
const response = await fetch('{{ url_for(request, "/dashboard/response-cache/clear") }}', { const response = await fetch('{{ url_for(request, "/dashboard/response-cache/clear") }}', {
...@@ -1114,14 +1178,13 @@ async function clearResponseCache() { ...@@ -1114,14 +1178,13 @@ async function clearResponseCache() {
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
alert('Response cache cleared successfully'); showAlert('Response cache cleared successfully', 'Done', '✅', 'info');
refreshCacheStats(); refreshCacheStats();
} else { } else {
alert('Failed to clear cache: ' + (data.error || 'Unknown error')); showAlert('Failed to clear cache: ' + (data.error || 'Unknown error'), 'Error', '❌', 'danger');
} }
} catch (error) { } catch (error) {
console.error('Error clearing cache:', error); showAlert('Error clearing cache: ' + error.message, 'Error', '❌', 'danger');
alert('Error clearing cache: ' + error.message);
} }
} }
</script> </script>
......
...@@ -117,36 +117,13 @@ document.querySelector('form')?.addEventListener('submit', function(e) { ...@@ -117,36 +117,13 @@ document.querySelector('form')?.addEventListener('submit', function(e) {
const password = document.getElementById('password').value; const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirm_password').value; const confirmPassword = document.getElementById('confirm_password').value;
if (password !== confirmPassword) { const check = (msg) => { e.preventDefault(); showAlert(msg, 'Validation Error', '⚠️', 'warn'); return false; };
e.preventDefault();
alert('Passwords do not match!');
return false;
}
// Check password strength if (password !== confirmPassword) return check('Passwords do not match!');
if (password.length < 8) { if (password.length < 8) return check('Password must be at least 8 characters long!');
e.preventDefault(); if (!/[a-z]/.test(password)) return check('Password must contain at least one lowercase letter!');
alert('Password must be at least 8 characters long!'); if (!/[A-Z]/.test(password)) return check('Password must contain at least one uppercase letter!');
return false; if (!/\d/.test(password)) return check('Password must contain at least one number!');
}
if (!/[a-z]/.test(password)) {
e.preventDefault();
alert('Password must contain at least one lowercase letter!');
return false;
}
if (!/[A-Z]/.test(password)) {
e.preventDefault();
alert('Password must contain at least one uppercase letter!');
return false;
}
if (!/\d/.test(password)) {
e.preventDefault();
alert('Password must contain at least one number!');
return false;
}
return true; return true;
}); });
...@@ -189,7 +166,7 @@ function openOAuthPopup(url) { ...@@ -189,7 +166,7 @@ function openOAuthPopup(url) {
window.removeEventListener('message', messageListener); window.removeEventListener('message', messageListener);
// Show error // Show error
alert(event.data.error || 'Authentication failed'); showAlert(event.data.error || 'Authentication failed', 'Authentication Error', '❌', 'danger');
} }
}; };
......
...@@ -315,14 +315,14 @@ function renderAutoselectModels(autoselectKey) { ...@@ -315,14 +315,14 @@ function renderAutoselectModels(autoselectKey) {
}); });
} }
function addAutoselect() { async function addAutoselect() {
const key = prompt('Enter autoselect key (e.g., "autoselect", "smart-select"):'); const key = await showPrompt('Enter autoselect key (e.g., "autoselect", "smart-select"):', '', 'e.g. autoselect', 'Add Autoselect');
if (!key) { if (!key) {
return; return;
} }
if (autoselectConfig[key]) { if (autoselectConfig[key]) {
alert('Autoselect key already exists'); showAlert('Autoselect key already exists', 'Duplicate Key', '⚠️', 'warn');
return; return;
} }
...@@ -338,8 +338,8 @@ function addAutoselect() { ...@@ -338,8 +338,8 @@ function addAutoselect() {
renderAutoselectList(); renderAutoselectList();
} }
function removeAutoselect(key) { async function removeAutoselect(key) {
if (confirm(`Remove autoselect "${key}"?`)) { if (await showDangerConfirm(`Remove autoselect "${key}"?`, 'Remove Autoselect')) {
delete autoselectConfig[key]; delete autoselectConfig[key];
expandedAutoselects.delete(key); expandedAutoselects.delete(key);
renderAutoselectList(); renderAutoselectList();
...@@ -375,8 +375,8 @@ function addAutoselectModel(autoselectKey) { ...@@ -375,8 +375,8 @@ function addAutoselectModel(autoselectKey) {
renderAutoselectModels(autoselectKey); renderAutoselectModels(autoselectKey);
} }
function removeAutoselectModel(autoselectKey, index) { async function removeAutoselectModel(autoselectKey, index) {
if (confirm('Remove this model?')) { if (await showDangerConfirm('Remove this model?', 'Remove Model')) {
autoselectConfig[autoselectKey].available_models.splice(index, 1); autoselectConfig[autoselectKey].available_models.splice(index, 1);
renderAutoselectModels(autoselectKey); renderAutoselectModels(autoselectKey);
} }
...@@ -399,10 +399,10 @@ async function saveAutoselect() { ...@@ -399,10 +399,10 @@ async function saveAutoselect() {
if (response.ok) { if (response.ok) {
window.location.href = autoselectData.successUrl; window.location.href = autoselectData.successUrl;
} else { } else {
alert('Error saving configuration'); showAlert('Error saving configuration', 'Error', '❌', 'danger');
} }
} catch (error) { } catch (error) {
alert('Error: ' + error.message); showAlert('Error: ' + error.message, 'Error', '❌', 'danger');
} }
} }
......
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}User Dashboard - AISBF{% endblock %} {% block title %}Dashboard - AISBF{% endblock %}
{% macro format_tokens(value) %} {% macro format_tokens(value) %}
{% if value is none or value == 0 %}0{% else %} {% if value is none or value == 0 %}0{% else %}
...@@ -18,453 +18,550 @@ ...@@ -18,453 +18,550 @@
{% endmacro %} {% endmacro %}
{% block content %} {% block content %}
<div class="container">
<h1>User Dashboard</h1>
<p>Welcome, {{ session.username }}!</p>
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<!-- API Documentation Section -->
<div class="card">
<h2>🔌 Your API Endpoints</h2>
<p>Use your API token to access your personal configurations. Include the token in the Authorization header:</p>
<pre style="background: #1a1a2e; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code>Authorization: Bearer YOUR_API_TOKEN</code></pre>
<div class="api-endpoints">
<h3>Your Models</h3>
<div class="endpoint">
<code class="method GET">GET</code>
<code class="url">{{ get_base_url(request) }}/api/u/{{ session.username }}/models</code>
<p>List all models from your providers, rotations, and autoselects</p>
</div>
<h3>Your Providers</h3>
<div class="endpoint">
<code class="method GET">GET</code>
<code class="url">{{ get_base_url(request) }}/api/u/{{ session.username }}/providers</code>
<p>List all your configured providers</p>
</div>
<h3>Your Rotations</h3>
<div class="endpoint">
<code class="method GET">GET</code>
<code class="url">{{ get_base_url(request) }}/api/u/{{ session.username }}/rotations</code>
<p>List all your configured rotations</p>
</div>
<h3>Your Autoselects</h3>
<div class="endpoint">
<code class="method GET">GET</code>
<code class="url">{{ get_base_url(request) }}/api/u/{{ session.username }}/autoselects</code>
<p>List all your configured autoselects</p>
</div>
<h3>Your Chat Completions</h3>
<div class="endpoint">
<code class="method POST">POST</code>
<code class="url">{{ get_base_url(request) }}/api/u/{{ session.username }}/chat/completions</code>
<p>Send chat requests using your configurations</p>
<p class="example">Example model formats:</p>
<ul>
<li><code>user-provider/myprovider/mymodel</code></li>
<li><code>user-rotation/myrotation</code></li>
<li><code>user-autoselect/myautoselect</code></li>
</ul>
</div>
<h3>MCP Tools</h3>
<div class="endpoint">
<code class="method GET">GET</code>
<code class="url">{{ get_base_url(request) }}/mcp/u/{{ session.username }}/tools</code>
<p>List available MCP tools for your configurations</p>
</div>
<div class="endpoint">
<code class="method POST">POST</code>
<code class="url">{{ get_base_url(request) }}/mcp/u/{{ session.username }}/tools/call</code>
<p>Call MCP tools to manage your configurations</p>
</div>
</div>
{% if session.role == 'admin' %}
<div class="admin-notice">
<h4>⚡ Admin Access</h4>
<p>As an admin user, you also have access to global configurations (providers, rotations, autoselects configured by the admin) in addition to your own user configurations.</p>
<p class="example">Admin model formats:</p>
<ul>
<li><code>provider/model</code> - global provider</li>
<li><code>rotation/myrotation</code> - global rotation</li>
<li><code>autoselect/myautoselect</code> - global autoselect</li>
</ul>
</div>
{% endif %}
<p class="note">⚠️ Note: Your API token is required for all these endpoints. Manage your tokens in the <a href="{{ url_for(request, '/dashboard/user/tokens') }}">API Tokens</a> section.</p>
</div>
<!-- Usage Statistics -->
<div class="card">
<h2>Usage Statistics</h2>
<div class="stats-grid">
<div class="stat-item">
<h3>Total Tokens Used</h3>
<p class="stat-value">{{ format_tokens(usage_stats.total_tokens|default(0)) }}</p>
</div>
<div class="stat-item">
<h3>Requests Today</h3>
<p class="stat-value">{{ usage_stats.requests_today|default(0) }}</p>
</div>
<div class="stat-item">
<h3>Active Providers</h3>
<p class="stat-value">{{ providers_count|default(0) }}</p>
</div>
<div class="stat-item">
<h3>Active Rotations</h3>
<p class="stat-value">{{ rotations_count|default(0) }}</p>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card">
<h2>Quick Actions</h2>
<div class="button-group">
<a href="{{ url_for(request, '/dashboard/user/providers') }}" class="btn btn-primary">Manage Providers</a>
<a href="{{ url_for(request, '/dashboard/user/rotations') }}" class="btn btn-primary">Manage Rotations</a>
<a href="{{ url_for(request, '/dashboard/user/autoselect') }}" class="btn btn-primary">Manage Autoselect</a>
<a href="{{ url_for(request, '/dashboard/user/tokens') }}" class="btn btn-primary">API Tokens</a>
</div>
</div>
<!-- Recent Activity -->
<div class="card">
<h2>Recent Activity</h2>
<table class="table">
<thead>
<tr>
<th>Timestamp</th>
<th>Provider</th>
<th>Model</th>
<th>Tokens</th>
</tr>
</thead>
<tbody>
{% if recent_activity %}
{% for activity in recent_activity %}
<tr>
<td>{{ activity.timestamp }}</td>
<td>{{ activity.provider_id }}</td>
<td>{{ activity.model }}</td>
<td>{{ activity.token_count }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" style="text-align: center;">No recent activity</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<!-- Subscription Status -->
{% if current_tier %}
<div class="card">
<h2>Subscription</h2>
<div class="subscription-info">
<div class="plan-info">
<h3>{{ current_tier.name }}</h3>
{% if current_tier.is_default %}
<span class="free-tier-badge">Free Tier</span>
{% else %}
<div class="plan-price">
<span class="price">{{ currency_symbol }}{{ current_tier.price_monthly }}/month</span>
<span class="price-secondary">or {{ currency_symbol }}{{ current_tier.price_yearly }}/year</span>
</div>
{% endif %}
</div>
{% if subscription %}
<div class="subscription-details">
<div class="subscription-status">
<span class="status-badge status-{{ subscription.status }}">{{ subscription.status|title }}</span>
{% if subscription.expires_at %}
<span class="renewal-date">Renews: {{ subscription.expires_at }}</span>
{% endif %}
</div>
</div>
{% endif %}
{% if not payment_methods or payment_methods|length == 0 %}
<div class="payment-method-section">
<a href="{{ url_for(request, '/dashboard/billing/add-method') }}" class="btn btn-primary">Add Payment Method</a>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
<style> <style>
.stats-grid { /* ── Hero ──────────────────────────────────────────────────── */
display: grid; .hero {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); display: flex;
gap: 1rem; align-items: center;
margin-top: 1rem; gap: 1.5rem;
} padding: 1.5rem 2rem;
background: linear-gradient(135deg, #0f3460 0%, #16213e 60%, #1a1a2e 100%);
.stat-item { border-radius: 12px;
background: #1a1a2e; margin-bottom: 1.75rem;
padding: 1.5rem; border: 1px solid #0f3460;
border-radius: 8px; position: relative;
text-align: center; overflow: hidden;
} }
.hero::before {
.stat-item h3 { content: '';
font-size: 0.9rem; position: absolute;
color: #888; top: -60px; right: -60px;
margin-bottom: 0.5rem; width: 200px; height: 200px;
background: radial-gradient(circle, rgba(103,126,234,.18) 0%, transparent 70%);
pointer-events: none;
}
.hero-avatar img {
width: 64px; height: 64px;
border-radius: 50%;
border: 3px solid #667eea;
box-shadow: 0 0 16px rgba(103,126,234,.4);
}
.hero-text h2 { font-size: 1.5rem; font-weight: 700; color: #e0e0e0; margin-bottom: .25rem; }
.hero-text p { color: #a0a0a0; font-size: .9rem; }
.hero-badge {
margin-left: auto;
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
padding: .35rem .9rem;
border-radius: 20px;
font-size: .8rem;
font-weight: 600;
white-space: nowrap;
} }
.stat-value { /* ── Stat cards ─────────────────────────────────────────────── */
font-size: 2rem; .stats-row {
font-weight: bold; display: grid;
color: #16213e; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 1rem;
margin-bottom: 1.75rem;
} }
.stat-card {
.button-group { background: #16213e;
border: 1px solid #0f3460;
border-radius: 10px;
padding: 1.25rem 1.5rem;
display: flex; display: flex;
align-items: center;
gap: 1rem; gap: 1rem;
flex-wrap: wrap; transition: border-color .2s;
margin-top: 1rem; }
} .stat-card:hover { border-color: #667eea; }
.stat-icon {
.table { width: 44px; height: 44px;
width: 100%; border-radius: 10px;
margin-top: 1rem; display: flex; align-items: center; justify-content: center;
border-collapse: collapse; font-size: 1.2rem;
} flex-shrink: 0;
}
.table th, .stat-icon.purple { background: rgba(103,126,234,.15); color: #667eea; }
.table td { .stat-icon.green { background: rgba(74,222,128,.12); color: #4ade80; }
padding: 0.75rem; .stat-icon.blue { background: rgba(96,165,250,.12); color: #60a5fa; }
text-align: left; .stat-icon.orange { background: rgba(251,146,60,.12); color: #fb923c; }
border-bottom: 1px solid #333; .stat-label { font-size: .78rem; color: #888; margin-bottom: .25rem; }
} .stat-value { font-size: 1.6rem; font-weight: 700; color: #e0e0e0; line-height: 1; }
.table th { /* ── Two-column layout ──────────────────────────────────────── */
background: #1a1a2e; .dashboard-grid {
font-weight: bold; display: grid;
} grid-template-columns: 1fr 1fr;
gap: 1.5rem;
.table tbody tr:hover { margin-bottom: 1.75rem;
background: #1a1a2e;
}
/* API Endpoints Section */
.api-endpoints {
margin-top: 1rem;
}
.api-endpoints h3 {
margin-top: 1.5rem;
color: #e0e0e0;
font-size: 1.1rem;
}
.endpoint {
background: #0f3460;
padding: 1rem;
margin: 0.5rem 0;
border-radius: 4px;
border-left: 3px solid #667eea;
} }
@media (max-width: 900px) { .dashboard-grid { grid-template-columns: 1fr; } }
.endpoint .method { /* ── Section card ───────────────────────────────────────────── */
display: inline-block; .section-card {
padding: 0.25rem 0.5rem; background: #16213e;
border-radius: 3px; border: 1px solid #0f3460;
font-weight: bold; border-radius: 10px;
font-size: 0.8rem; overflow: hidden;
margin-right: 0.5rem;
} }
.section-card.full-width { grid-column: 1 / -1; }
.endpoint .method.GET { .section-header {
background: #28a745; display: flex;
color: white; align-items: center;
} justify-content: space-between;
padding: .9rem 1.25rem;
.endpoint .method.POST { border-bottom: 1px solid #0f3460;
background: #007bff; background: rgba(15,52,96,.3);
color: white;
} }
.section-header h3 {
.endpoint .url { font-size: 1rem;
font-weight: 600;
color: #e0e0e0; color: #e0e0e0;
font-family: monospace; display: flex; align-items: center; gap: .5rem;
}
.endpoint p {
margin: 0.5rem 0 0 0;
color: #a0a0a0;
font-size: 0.9rem;
}
.endpoint .example {
color: #667eea;
}
.endpoint ul {
margin: 0.5rem 0 0 0;
padding-left: 1.5rem;
color: #a0a0a0;
} }
.section-header h3 i { color: #667eea; font-size: .9rem; }
.section-body { padding: 1.25rem; }
.endpoint code { /* ── Quick actions ──────────────────────────────────────────── */
background: #1a1a2e; .actions-grid {
padding: 0.2rem 0.4rem; display: grid;
border-radius: 3px; grid-template-columns: 1fr 1fr;
font-size: 0.9rem; gap: .75rem;
} }
.action-card {
.note { display: flex;
margin-top: 1rem; align-items: center;
padding: 1rem; gap: .75rem;
padding: .85rem 1rem;
background: #1a1a2e; background: #1a1a2e;
border-radius: 4px; border: 1px solid #0f3460;
color: #a0a0a0;
}
.note a {
color: #667eea;
}
.admin-notice {
margin-top: 1.5rem;
padding: 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px; border-radius: 8px;
color: white; text-decoration: none;
} color: #e0e0e0;
transition: border-color .2s, background .2s, transform .15s;
.admin-notice h4 {
margin: 0 0 0.5rem 0;
color: white;
}
.admin-notice p {
margin: 0.5rem 0;
color: white;
}
.admin-notice .example {
color: #ffd700;
}
.admin-notice ul {
margin: 0.5rem 0 0 0;
padding-left: 1.5rem;
} }
.action-card:hover {
.admin-notice code { border-color: #667eea;
background: rgba(0,0,0,0.3); background: rgba(103,126,234,.08);
padding: 0.2rem 0.4rem; transform: translateY(-1px);
border-radius: 3px;
} }
.action-card i { font-size: 1.1rem; color: #667eea; width: 20px; text-align: center; }
.action-card span { font-size: .9rem; font-weight: 500; }
.action-card .arrow { margin-left: auto; color: #444; font-size: .75rem; }
/* Subscription Section */ /* ── Subscription card ──────────────────────────────────────── */
.subscription-info { .sub-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
} }
.sub-plan h4 { font-size: 1.1rem; font-weight: 700; color: #e0e0e0; margin-bottom: .3rem; }
.plan-info h3 { .badge {
margin: 0 0 0.5rem 0; display: inline-block;
color: #e0e0e0; padding: .25rem .7rem;
border-radius: 12px;
font-size: .75rem;
font-weight: 600;
}
.badge-free { background: rgba(74,222,128,.15); color: #4ade80; border: 1px solid #4ade80; }
.badge-active { background: rgba(74,222,128,.15); color: #4ade80; border: 1px solid #4ade80; }
.badge-canceled{ background: rgba(255,193,7,.15); color: #ffc107; border: 1px solid #ffc107; }
.badge-expired { background: rgba(239,68,68,.15); color: #ef4444; border: 1px solid #ef4444; }
.sub-price { font-size: 1.4rem; font-weight: 700; color: #4ade80; }
.sub-price-sub { font-size: .8rem; color: #888; }
.renew-label { font-size: .8rem; color: #888; margin-top: .25rem; }
/* ── Activity table ─────────────────────────────────────────── */
.activity-table { width: 100%; border-collapse: collapse; }
.activity-table th {
padding: .6rem .9rem;
font-size: .78rem;
text-transform: uppercase;
letter-spacing: .05em;
color: #666;
border-bottom: 1px solid #0f3460;
text-align: left;
} }
.activity-table td {
.free-tier-badge { padding: .7rem .9rem;
background: #28a745; font-size: .88rem;
color: white; color: #c0c0c0;
padding: 4px 12px; border-bottom: 1px solid rgba(15,52,96,.5);
border-radius: 15px; }
font-size: 12px; .activity-table tr:last-child td { border-bottom: none; }
font-weight: bold; .activity-table tr:hover td { background: rgba(15,52,96,.25); }
.activity-table .mono { font-family: monospace; font-size: .82rem; }
.empty-row td { text-align: center; color: #555; padding: 2rem; }
/* ── API Endpoints section ──────────────────────────────────── */
.endpoints-toggle {
cursor: pointer;
user-select: none;
}
.endpoints-toggle .caret { transition: transform .2s; display: inline-block; }
.endpoints-toggle.open .caret { transform: rotate(180deg); }
.endpoints-body { display: none; }
.endpoints-body.visible { display: block; }
.ep-group { margin-bottom: 1rem; }
.ep-group-title {
font-size: .78rem;
text-transform: uppercase;
letter-spacing: .08em;
color: #667eea;
margin-bottom: .4rem;
font-weight: 600;
} }
.ep-row {
.plan-price { display: flex;
margin-top: 0.5rem; align-items: flex-start;
gap: .75rem;
background: #1a1a2e;
border: 1px solid #0f3460;
border-radius: 6px;
padding: .6rem .9rem;
margin-bottom: .4rem;
}
.ep-row + .ep-row { border-top: none; border-radius: 0; margin-top: -1px; }
.ep-row:first-child { border-radius: 6px 6px 0 0; }
.ep-row:last-child { border-radius: 0 0 6px 6px; }
.ep-row:only-child { border-radius: 6px; }
.method-badge {
font-size: .7rem;
font-weight: 700;
padding: .2rem .5rem;
border-radius: 4px;
flex-shrink: 0;
margin-top: .05rem;
}
.method-GET { background: rgba(74,222,128,.18); color: #4ade80; }
.method-POST { background: rgba(96,165,250,.18); color: #60a5fa; }
.ep-url { font-family: monospace; font-size: .82rem; color: #c0c0c0; word-break: break-all; }
.ep-desc { font-size: .8rem; color: #666; margin-top: .15rem; }
.code-block {
background: #0f1c33;
border: 1px solid #0f3460;
border-radius: 6px;
padding: .9rem 1rem;
font-family: monospace;
font-size: .82rem;
color: #c0c0c0;
overflow-x: auto;
margin: .75rem 0 0;
} }
.price { .admin-banner {
font-size: 1.5rem; background: linear-gradient(135deg, rgba(103,126,234,.2) 0%, rgba(118,75,162,.2) 100%);
font-weight: bold; border: 1px solid rgba(103,126,234,.4);
color: #4ade80; border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
} }
.admin-banner h4 { color: #a78bfa; margin-bottom: .4rem; font-size: .9rem; }
.admin-banner p { color: #c0c0c0; font-size: .85rem; margin: .25rem 0; }
.admin-banner code { background: rgba(0,0,0,.3); padding: .15rem .4rem; border-radius: 3px; font-size: .8rem; }
.price-secondary { .tip-box {
background: rgba(15,52,96,.5);
border-left: 3px solid #667eea;
border-radius: 0 6px 6px 0;
padding: .7rem 1rem;
font-size: .85rem;
color: #a0a0a0; color: #a0a0a0;
font-size: 0.9rem; margin-top: .75rem;
margin-left: 0.5rem;
} }
.tip-box a { color: #667eea; }
</style>
.subscription-details { {% if success %}
text-align: center; <div class="alert alert-success" style="margin-bottom:1.25rem;">{{ success }}</div>
} {% endif %}
{% if error %}
<div class="alert alert-error" style="margin-bottom:1.25rem;">{{ error }}</div>
{% endif %}
.subscription-status { <!-- Hero -->
display: flex; <div class="hero">
flex-direction: column; <div class="hero-avatar">
align-items: center; <img src="https://www.gravatar.com/avatar/{{ session.email|md5 if session.email else '' }}?s=128&d=identicon" alt="avatar">
gap: 0.5rem; </div>
} <div class="hero-text">
<h2>Welcome back, {{ display_name }}!</h2>
<p>Manage your AI configurations, track usage, and access your personal API endpoints.</p>
</div>
{% if session.role == 'admin' %}
<div class="hero-badge">Admin</div>
{% endif %}
</div>
.status-badge { <!-- Stats row -->
padding: 4px 12px; <div class="stats-row">
border-radius: 15px; <div class="stat-card">
font-size: 12px; <div class="stat-icon purple"><i class="fas fa-coins"></i></div>
font-weight: bold; <div>
} <div class="stat-label">Total Tokens</div>
<div class="stat-value">{{ format_tokens(usage_stats.total_tokens|default(0)) }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon green"><i class="fas fa-bolt"></i></div>
<div>
<div class="stat-label">Requests Today</div>
<div class="stat-value">{{ usage_stats.requests_today|default(0) }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon blue"><i class="fas fa-plug"></i></div>
<div>
<div class="stat-label">Active Providers</div>
<div class="stat-value">{{ providers_count|default(0) }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon orange"><i class="fas fa-rotate"></i></div>
<div>
<div class="stat-label">Active Rotations</div>
<div class="stat-value">{{ rotations_count|default(0) }}</div>
</div>
</div>
</div>
.status-active { <!-- Main grid -->
background: #28a745; <div class="dashboard-grid">
color: white;
}
.status-canceled { <!-- Quick Actions -->
background: #ffc107; <div class="section-card">
color: black; <div class="section-header">
} <h3><i class="fas fa-grid-2"></i> Quick Actions</h3>
</div>
<div class="section-body">
<div class="actions-grid">
<a href="{{ url_for(request, '/dashboard/user/providers') }}" class="action-card">
<i class="fas fa-plug"></i>
<span>Providers</span>
<i class="fas fa-chevron-right arrow"></i>
</a>
<a href="{{ url_for(request, '/dashboard/user/rotations') }}" class="action-card">
<i class="fas fa-rotate"></i>
<span>Rotations</span>
<i class="fas fa-chevron-right arrow"></i>
</a>
<a href="{{ url_for(request, '/dashboard/user/autoselect') }}" class="action-card">
<i class="fas fa-wand-magic-sparkles"></i>
<span>Autoselect</span>
<i class="fas fa-chevron-right arrow"></i>
</a>
<a href="{{ url_for(request, '/dashboard/user/tokens') }}" class="action-card">
<i class="fas fa-key"></i>
<span>API Tokens</span>
<i class="fas fa-chevron-right arrow"></i>
</a>
<a href="{{ url_for(request, '/dashboard/analytics') }}" class="action-card">
<i class="fas fa-chart-line"></i>
<span>Analytics</span>
<i class="fas fa-chevron-right arrow"></i>
</a>
<a href="{{ url_for(request, '/dashboard/wallet') }}" class="action-card">
<i class="fas fa-wallet"></i>
<span>Wallet</span>
<i class="fas fa-chevron-right arrow"></i>
</a>
</div>
</div>
</div>
.status-expired, <!-- Subscription -->
.status-suspended { {% if current_tier %}
background: #dc3545; <div class="section-card">
color: white; <div class="section-header">
} <h3><i class="fas fa-crown"></i> Subscription</h3>
{% if not current_tier.is_default %}
<a href="{{ url_for(request, '/dashboard/subscription') }}" style="font-size:.8rem; color:#667eea; text-decoration:none;">Manage</a>
{% endif %}
</div>
<div class="section-body">
<div class="sub-row">
<div class="sub-plan">
<h4>{{ current_tier.name }}</h4>
{% if current_tier.is_default %}
<span class="badge badge-free">Free Tier</span>
{% else %}
<div class="sub-price">{{ currency_symbol }}{{ current_tier.price_monthly }}<span style="font-size:.9rem;color:#888;">/mo</span></div>
<div class="sub-price-sub">or {{ currency_symbol }}{{ current_tier.price_yearly }}/year</div>
{% endif %}
</div>
{% if subscription %}
<div style="text-align:right;">
<span class="badge badge-{{ subscription.status }}">{{ subscription.status|title }}</span>
{% if subscription.expires_at %}
<div class="renew-label">Renews {{ subscription.expires_at }}</div>
{% endif %}
</div>
{% endif %}
</div>
{% if not payment_methods or payment_methods|length == 0 %}
<a href="{{ url_for(request, '/dashboard/billing/add-method') }}" class="btn btn-primary" style="margin-top:1rem; margin-left:0; display:inline-block;">Add Payment Method</a>
{% endif %}
</div>
</div>
{% endif %}
.renewal-date { <!-- Recent Activity -->
color: #a0a0a0; <div class="section-card full-width">
font-size: 0.9rem; <div class="section-header">
} <h3><i class="fas fa-clock-rotate-left"></i> Recent Activity</h3>
</div>
<div class="section-body" style="padding:0;">
<table class="activity-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Provider</th>
<th>Model</th>
<th>Tokens</th>
</tr>
</thead>
<tbody>
{% if recent_activity %}
{% for activity in recent_activity %}
<tr>
<td class="mono">{{ activity.timestamp }}</td>
<td>{{ activity.provider_id }}</td>
<td class="mono">{{ activity.model }}</td>
<td>{{ activity.token_count }}</td>
</tr>
{% endfor %}
{% else %}
<tr class="empty-row"><td colspan="4">No recent activity yet. Make your first API request to see usage here.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
.payment-method-section { <!-- API Endpoints (collapsible) -->
text-align: center; <div class="section-card full-width">
margin-top: 1rem; <div class="section-header endpoints-toggle" id="epToggle" onclick="toggleEndpoints()">
} <h3><i class="fas fa-code"></i> Your API Endpoints</h3>
<span style="color:#667eea; font-size:.85rem;">Show / Hide &nbsp;<span class="caret">&#9660;</span></span>
</div>
<div class="endpoints-body" id="epBody">
<div class="section-body">
<p style="color:#a0a0a0; font-size:.9rem; margin-bottom:1rem;">
Include your API token in the <code style="background:#0f3460; padding:.15rem .4rem; border-radius:3px;">Authorization</code> header:
</p>
<div class="code-block">Authorization: Bearer YOUR_API_TOKEN</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:1.5rem; margin-top:1.25rem;">
<div>
<div class="ep-group">
<div class="ep-group-title">Models</div>
<div class="ep-row">
<span class="method-badge method-GET">GET</span>
<div>
<div class="ep-url">/api/u/{{ session.username }}/models</div>
<div class="ep-desc">List all your models</div>
</div>
</div>
</div>
<div class="ep-group">
<div class="ep-group-title">Providers</div>
<div class="ep-row">
<span class="method-badge method-GET">GET</span>
<div>
<div class="ep-url">/api/u/{{ session.username }}/providers</div>
<div class="ep-desc">List your configured providers</div>
</div>
</div>
</div>
<div class="ep-group">
<div class="ep-group-title">Rotations &amp; Autoselect</div>
<div class="ep-row">
<span class="method-badge method-GET">GET</span>
<div>
<div class="ep-url">/api/u/{{ session.username }}/rotations</div>
<div class="ep-desc">List your rotations</div>
</div>
</div>
<div class="ep-row">
<span class="method-badge method-GET">GET</span>
<div>
<div class="ep-url">/api/u/{{ session.username }}/autoselects</div>
<div class="ep-desc">List your autoselects</div>
</div>
</div>
</div>
</div>
<div>
<div class="ep-group">
<div class="ep-group-title">Chat Completions</div>
<div class="ep-row">
<span class="method-badge method-POST">POST</span>
<div>
<div class="ep-url">/api/u/{{ session.username }}/chat/completions</div>
<div class="ep-desc">Send chat requests using your configs</div>
</div>
</div>
</div>
<div class="ep-group">
<div class="ep-group-title">MCP Tools</div>
<div class="ep-row">
<span class="method-badge method-GET">GET</span>
<div>
<div class="ep-url">/mcp/u/{{ session.username }}/tools</div>
<div class="ep-desc">List MCP tools</div>
</div>
</div>
<div class="ep-row">
<span class="method-badge method-POST">POST</span>
<div>
<div class="ep-url">/mcp/u/{{ session.username }}/tools/call</div>
<div class="ep-desc">Call MCP tools</div>
</div>
</div>
</div>
<div class="ep-group">
<div class="ep-group-title">Model format examples</div>
<div style="background:#1a1a2e; border:1px solid #0f3460; border-radius:6px; padding:.75rem 1rem; font-family:monospace; font-size:.82rem; color:#a0a0a0; line-height:1.6;">
user-provider/<em>myprovider</em>/<em>mymodel</em><br>
user-rotation/<em>myrotation</em><br>
user-autoselect/<em>myautoselect</em>
</div>
</div>
</div>
</div>
{% if session.role == 'admin' %}
<div class="admin-banner">
<h4><i class="fas fa-shield-halved"></i> Admin Access</h4>
<p>As an admin you also access global configurations via shorter model formats:</p>
<p><code>provider/model</code> &nbsp;&bull;&nbsp; <code>rotation/myrotation</code> &nbsp;&bull;&nbsp; <code>autoselect/myautoselect</code></p>
</div>
{% endif %}
<div class="tip-box">
<i class="fas fa-circle-info" style="color:#667eea;"></i>
&nbsp;Your API token is required for all endpoints.
<a href="{{ url_for(request, '/dashboard/user/tokens') }}">Manage your tokens &rarr;</a>
</div>
</div>
</div>
</div>
@media (max-width: 768px) { </div>
.subscription-info {
flex-direction: column; <script>
text-align: center; function toggleEndpoints() {
} const toggle = document.getElementById('epToggle');
const body = document.getElementById('epBody');
toggle.classList.toggle('open');
body.classList.toggle('visible');
} }
</style> </script>
{% endblock %} {% endblock %}
...@@ -256,7 +256,7 @@ function showToast(message, type) { ...@@ -256,7 +256,7 @@ function showToast(message, type) {
let providersData = {}; let providersData = {};
let expandedProviders = new Set(); let expandedProviders = new Set();
let rawProviders = {{ user_providers_json | safe }}; let rawProviders = {{ user_providers_json | replace("</script>", "<\\/script>") | safe }};
// Convert user providers format to the format expected by the UI // Convert user providers format to the format expected by the UI
rawProviders.forEach(provider => { rawProviders.forEach(provider => {
...@@ -1705,8 +1705,8 @@ async function pollKiloAuth(providerKey, deviceCode) { ...@@ -1705,8 +1705,8 @@ async function pollKiloAuth(providerKey, deviceCode) {
// This function is no longer needed as polling is handled in authenticateKilo // This function is no longer needed as polling is handled in authenticateKilo
} }
function removeProvider(key) { async function removeProvider(key) {
if (confirm(`Remove provider "${key}"?`)) { if (await showDangerConfirm(`Remove provider "${key}"?`, 'Remove Provider')) {
delete providersData[key]; delete providersData[key];
expandedProviders.delete(key); expandedProviders.delete(key);
renderProvidersList(); renderProvidersList();
...@@ -1786,8 +1786,8 @@ function addModel(key) { ...@@ -1786,8 +1786,8 @@ function addModel(key) {
renderModels(key); renderModels(key);
} }
function removeModel(providerKey, index) { async function removeModel(providerKey, index) {
if (confirm('Remove this model?')) { if (await showDangerConfirm('Remove this model?', 'Remove Model')) {
providersData[providerKey].models.splice(index, 1); providersData[providerKey].models.splice(index, 1);
renderModels(providerKey); renderModels(providerKey);
} }
...@@ -1798,12 +1798,12 @@ async function confirmAddProvider() { ...@@ -1798,12 +1798,12 @@ async function confirmAddProvider() {
const type = document.getElementById('new-provider-type').value; const type = document.getElementById('new-provider-type').value;
if (!key) { if (!key) {
alert('Please enter a provider key'); showAlert('Please enter a provider key', 'Missing Key', '⚠️', 'warn');
return; return;
} }
if (providersData[key]) { if (providersData[key]) {
alert('Provider key already exists'); showAlert('Provider key already exists', 'Duplicate Key', '⚠️', 'warn');
return; return;
} }
...@@ -1931,10 +1931,10 @@ async function saveProviders() { ...@@ -1931,10 +1931,10 @@ async function saveProviders() {
if (response.ok) { if (response.ok) {
window.location.href = '/dashboard/providers?success=1'; window.location.href = '/dashboard/providers?success=1';
} else { } else {
alert('Error saving configuration'); showAlert('Error saving configuration', 'Error', '❌', 'danger');
} }
} catch (error) { } catch (error) {
alert('Error: ' + error.message); showAlert('Error: ' + error.message, 'Error', '❌', 'danger');
} }
} }
......
...@@ -259,10 +259,11 @@ function renderRotationModels(rotationKey, providerIndex) { ...@@ -259,10 +259,11 @@ function renderRotationModels(rotationKey, providerIndex) {
}); });
} }
function addRotation() { async function addRotation() {
const key = prompt('Enter rotation key (e.g., "coding", "general"):'); const key = await showPrompt('Enter rotation key (e.g., "coding", "general"):', '', 'rotation-key', 'Add Rotation');
if (!key || rotationsConfig.rotations[key]) { if (!key) return;
alert('Invalid or duplicate rotation key'); if (rotationsConfig.rotations[key]) {
showAlert('Rotation key already exists', 'Duplicate Key', '⚠️', 'warn');
return; return;
} }
...@@ -281,8 +282,8 @@ function addRotation() { ...@@ -281,8 +282,8 @@ function addRotation() {
renderRotationsList(); renderRotationsList();
} }
function removeRotation(key) { async function removeRotation(key) {
if (confirm(`Remove rotation "${key}"?`)) { if (await showDangerConfirm(`Remove rotation "${key}"?`, 'Remove Rotation')) {
delete rotationsConfig.rotations[key]; delete rotationsConfig.rotations[key];
expandedRotations.delete(key); expandedRotations.delete(key);
renderRotationsList(); renderRotationsList();
...@@ -318,8 +319,8 @@ function addRotationProvider(rotationKey) { ...@@ -318,8 +319,8 @@ function addRotationProvider(rotationKey) {
renderRotationProviders(rotationKey); renderRotationProviders(rotationKey);
} }
function removeRotationProvider(rotationKey, providerIndex) { async function removeRotationProvider(rotationKey, providerIndex) {
if (confirm('Remove this provider?')) { if (await showDangerConfirm('Remove this provider?', 'Remove Provider')) {
rotationsConfig.rotations[rotationKey].providers.splice(providerIndex, 1); rotationsConfig.rotations[rotationKey].providers.splice(providerIndex, 1);
renderRotationProviders(rotationKey); renderRotationProviders(rotationKey);
} }
...@@ -344,8 +345,8 @@ function addRotationModel(rotationKey, providerIndex) { ...@@ -344,8 +345,8 @@ function addRotationModel(rotationKey, providerIndex) {
renderRotationModels(rotationKey, providerIndex); renderRotationModels(rotationKey, providerIndex);
} }
function removeRotationModel(rotationKey, providerIndex, modelIndex) { async function removeRotationModel(rotationKey, providerIndex, modelIndex) {
if (confirm('Remove this model?')) { if (await showDangerConfirm('Remove this model?', 'Remove Model')) {
rotationsConfig.rotations[rotationKey].providers[providerIndex].models.splice(modelIndex, 1); rotationsConfig.rotations[rotationKey].providers[providerIndex].models.splice(modelIndex, 1);
renderRotationModels(rotationKey, providerIndex); renderRotationModels(rotationKey, providerIndex);
} }
...@@ -384,10 +385,10 @@ async function saveRotations() { ...@@ -384,10 +385,10 @@ async function saveRotations() {
if (response.ok) { if (response.ok) {
window.location.href = rotationsData.successUrl; window.location.href = rotationsData.successUrl;
} else { } else {
alert('Error saving configuration'); showAlert('Error saving configuration', 'Error', '❌', 'danger');
} }
} catch (error) { } catch (error) {
alert('Error: ' + error.message); showAlert('Error: ' + error.message, 'Error', '❌', 'danger');
} }
} }
......
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}My API Tokens - AISBF{% endblock %} {% block title %}API Tokens - AISBF{% endblock %}
{% block content %} {% block content %}
<div class="container"> <style>
<h1>My API Tokens</h1> /* ── Page header ──────────────────────────────────────────── */
<p>Manage your personal API tokens for accessing the AISBF API</p> .page-header {
display: flex;
{% if success %} align-items: center;
<div class="alert alert-success">{{ success }}</div> justify-content: space-between;
{% endif %} margin-bottom: 1.5rem;
flex-wrap: wrap;
{% if error %} gap: 1rem;
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<!-- API Documentation -->
<div class="card">
<h2>🔌 How to Use Your Token</h2>
<p>Include your API token in the <code>Authorization</code> header when making requests:</p>
<pre style="background: #1a1a2e; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code>Authorization: Bearer YOUR_API_TOKEN</code></pre>
<h3>Available Endpoints:</h3>
<table class="endpoints-table">
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="method GET">GET</code></td>
<td><code>/api/u/{{ session.username }}/models</code></td>
<td>List your models</td>
</tr>
<tr>
<td><code class="method GET">GET</code></td>
<td><code>/api/u/{{ session.username }}/providers</code></td>
<td>List your providers</td>
</tr>
<tr>
<td><code class="method GET">GET</code></td>
<td><code>/api/u/{{ session.username }}/rotations</code></td>
<td>List your rotations</td>
</tr>
<tr>
<td><code class="method GET">GET</code></td>
<td><code>/api/u/{{ session.username }}/autoselects</code></td>
<td>List your autoselects</td>
</tr>
<tr>
<td><code class="method POST">POST</code></td>
<td><code>/api/u/{{ session.username }}/chat/completions</code></td>
<td>Chat using your configs</td>
</tr>
<tr>
<td><code class="method GET">GET</code></td>
<td><code>/mcp/u/{{ session.username }}/tools</code></td>
<td>List MCP tools</td>
</tr>
<tr>
<td><code class="method POST">POST</code></td>
<td><code>/mcp/u/{{ session.username }}/tools/call</code></td>
<td>Call MCP tools</td>
</tr>
</tbody>
</table>
<h3>Example curl commands:</h3>
<pre style="background: #1a1a2e; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code># List your models
curl -H "Authorization: Bearer YOUR_TOKEN" {{ request.base_url }}api/u/{{ session.username }}/models
# List your providers
curl -H "Authorization: Bearer YOUR_TOKEN" {{ request.base_url }}api/u/{{ session.username }}/providers
# Send a chat request
curl -X POST -H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"model": "user-rotation/myrotation", "messages": [{"role": "user", "content": "Hello"}]}' \
{{ request.base_url }}api/u/{{ session.username }}/chat/completions</code></pre>
</div>
<div class="card">
<h2>API Tokens</h2>
<div id="tokens-list">
<!-- Tokens will be loaded here -->
</div>
<button class="btn btn-primary" onclick="createToken()">Create New Token</button>
</div>
<!-- Modal for creating tokens -->
<div id="token-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>Create New API Token</h3>
<button class="close-btn" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<div id="token-creation-form">
<form id="create-token-form">
<div class="form-group">
<label for="token-description">Description (optional):</label>
<input type="text" id="token-description" placeholder="e.g., My application token">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Create Token</button>
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
</div>
</form>
</div>
<div id="token-result" style="display: none;">
<div class="alert alert-success">
<h4>Token Created Successfully!</h4>
<p><strong>Token:</strong> <code id="created-token"></code></p>
<p class="warning-text">⚠️ <strong>Important:</strong> Copy this token now. You won't be able to see it again!</p>
<div class="form-actions">
<button type="button" class="btn btn-primary" onclick="copyToken()">Copy Token</button>
<button type="button" class="btn btn-secondary" onclick="closeModal()">Close</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
let tokens = {{ user_tokens | tojson }};
function renderTokens() {
const container = document.getElementById('tokens-list');
container.innerHTML = '';
if (tokens.length === 0) {
container.innerHTML = '<p class="empty-state">No API tokens created yet. Click "Create New Token" to get started.</p>';
return;
}
tokens.forEach((token) => {
const div = document.createElement('div');
div.className = 'token-item';
div.innerHTML = `
<div class="token-header">
<div class="token-info">
<h4>${token.description || 'No description'}</h4>
<p class="token-meta">
Created: ${new Date(token.created_at).toLocaleString()}
${token.last_used ? `| Last used: ${new Date(token.last_used).toLocaleString()}` : ''}
</p>
</div>
<div class="token-actions">
<span class="token-status ${token.is_active ? 'active' : 'inactive'}">
${token.is_active ? 'Active' : 'Inactive'}
</span>
<button class="btn btn-danger btn-sm" onclick="deleteToken(${token.id})">Delete</button>
</div>
</div>
<div class="token-details">
<p><strong>Token ID:</strong> ${token.id}</p>
<p><strong>Token:</strong> ${token.token.substring(0, 20)}...</p>
</div>
`;
container.appendChild(div);
});
} }
.page-header h2 {
function createToken() { font-size: 1.35rem;
document.getElementById('token-creation-form').style.display = 'block'; font-weight: 700;
document.getElementById('token-result').style.display = 'none'; color: #e0e0e0;
document.getElementById('create-token-form').reset(); display: flex;
document.getElementById('token-modal').style.display = 'block'; align-items: center;
gap: .6rem;
} }
.page-header h2 i { color: #667eea; }
.page-header p { color: #888; font-size: .9rem; margin-top: .2rem; }
function closeModal() { /* ── Section card ─────────────────────────────────────────── */
document.getElementById('token-modal').style.display = 'none'; .section-card {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 10px;
overflow: hidden;
margin-bottom: 1.5rem;
} }
.section-header {
function deleteToken(tokenId) { display: flex;
if (!confirm('Are you sure you want to delete this API token? This action cannot be undone and will immediately revoke access.')) return; align-items: center;
justify-content: space-between;
fetch('{{ url_for(request, "/dashboard/user/tokens") }}/' + tokenId, { padding: .85rem 1.25rem;
method: 'DELETE', border-bottom: 1px solid #0f3460;
headers: { background: rgba(15,52,96,.3);
'Content-Type': 'application/json'
}
}).then(response => {
if (response.ok) {
location.reload();
} else {
return response.json().then(data => {
throw new Error(data.error || 'Failed to delete token');
});
}
}).catch(error => {
alert('Error: ' + error.message);
});
} }
.section-header h3 {
function copyToken() { font-size: .95rem;
const tokenElement = document.getElementById('created-token'); font-weight: 600;
const token = tokenElement.textContent; color: #e0e0e0;
display: flex;
navigator.clipboard.writeText(token).then(() => { align-items: center;
alert('Token copied to clipboard!'); gap: .5rem;
}).catch(() => {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = token;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
alert('Token copied to clipboard!');
});
} }
.section-header h3 i { color: #667eea; font-size: .85rem; }
.section-body { padding: 1.25rem; }
// Handle form submission /* ── Token card ───────────────────────────────────────────── */
document.getElementById('create-token-form').addEventListener('submit', function(e) { .token-list { display: flex; flex-direction: column; gap: .85rem; }
e.preventDefault(); .token-card {
const description = document.getElementById('token-description').value.trim();
const formData = new FormData();
if (description) {
formData.append('description', description);
}
fetch('{{ url_for(request, "/dashboard/user/tokens") }}', {
method: 'POST',
body: formData
}).then(response => {
if (response.ok) {
return response.json();
} else {
return response.json().then(data => {
throw new Error(data.error || 'Failed to create token');
});
}
}).then(data => {
// Show the created token
document.getElementById('created-token').textContent = data.token;
document.getElementById('token-creation-form').style.display = 'none';
document.getElementById('token-result').style.display = 'block';
// Refresh the token list
tokens.push({
id: data.token_id,
token: data.token,
description: description || null,
created_at: new Date().toISOString(),
last_used: null,
is_active: true
});
renderTokens();
}).catch(error => {
alert('Error: ' + error.message);
});
});
renderTokens();
</script>
<style>
.token-item {
background: #1a1a2e; background: #1a1a2e;
padding: 1rem;
margin: 1rem 0;
border-radius: 8px;
border: 1px solid #0f3460; border: 1px solid #0f3460;
border-radius: 8px;
padding: 1rem 1.25rem;
transition: border-color .2s;
} }
.token-card:hover { border-color: #334477; }
.token-header { .token-card-top {
display: flex; display: flex;
justify-content: space-between;
align-items: flex-start; align-items: flex-start;
margin-bottom: 1rem; justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: .75rem;
} }
.token-name {
.token-info h4 { font-size: 1rem;
margin: 0 0 0.5rem 0; font-weight: 600;
color: #e0e0e0; color: #e0e0e0;
margin-bottom: .2rem;
} }
.token-meta { .token-meta {
color: #a0a0a0; font-size: .8rem;
font-size: 0.9rem; color: #666;
margin: 0;
}
.token-actions {
display: flex; display: flex;
align-items: center; gap: 1rem;
gap: 0.5rem; flex-wrap: wrap;
} }
.token-meta span { display: flex; align-items: center; gap: .3rem; }
.token-meta i { color: #445; font-size: .75rem; }
.token-status { .token-actions { display: flex; align-items: center; gap: .6rem; flex-shrink: 0; }
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
}
.token-status.active { .status-pill {
background: #28a745; display: inline-flex;
color: white; align-items: center;
gap: .3rem;
padding: .25rem .7rem;
border-radius: 20px;
font-size: .75rem;
font-weight: 600;
}
.status-pill::before {
content: '';
width: 6px; height: 6px;
border-radius: 50%;
background: currentColor;
} }
.status-active { background: rgba(74,222,128,.12); color: #4ade80; }
.status-inactive { background: rgba(239,68,68,.12); color: #ef4444; }
.token-status.inactive { .btn-sm {
background: #dc3545; padding: .35rem .8rem !important;
color: white; font-size: .8rem !important;
margin-left: 0 !important;
} }
.token-details { /* token value row */
background: #16213e; .token-value-row {
padding: 1rem; display: flex;
border-radius: 4px; align-items: center;
gap: .6rem;
background: #0f1c33;
border: 1px solid #0f3460;
border-radius: 6px;
padding: .55rem .85rem;
font-family: monospace; font-family: monospace;
font-size: 0.9rem; font-size: .85rem;
color: #a0a0a0;
} }
.token-value-text { flex: 1; word-break: break-all; }
.token-details p { .copy-btn {
margin: 0.5rem 0; background: rgba(103,126,234,.15);
word-break: break-all; border: 1px solid rgba(103,126,234,.3);
color: #667eea;
border-radius: 5px;
padding: .3rem .65rem;
font-size: .75rem;
cursor: pointer;
white-space: nowrap;
transition: background .2s;
flex-shrink: 0;
} }
.copy-btn:hover { background: rgba(103,126,234,.3); }
.copy-btn.copied { background: rgba(74,222,128,.2); color: #4ade80; border-color: rgba(74,222,128,.4); }
/* ── Empty state ──────────────────────────────────────────── */
.empty-state { .empty-state {
text-align: center; text-align: center;
color: #a0a0a0; padding: 3rem 1rem;
padding: 2rem; color: #555;
} }
.empty-state i {
.modal { font-size: 3rem;
position: fixed; margin-bottom: 1rem;
top: 0; display: block;
left: 0; color: #2a3a5e;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
} }
.empty-state p { font-size: .9rem; margin-bottom: 1.25rem; }
.modal-content { /* ── Create token form (inline) ───────────────────────────── */
.create-form-area {
display: none;
background: #1a1a2e; background: #1a1a2e;
border: 1px solid #667eea;
border-radius: 8px; border-radius: 8px;
width: 90%; padding: 1.25rem;
max-width: 500px; margin-top: 1rem;
max-height: 80vh;
overflow-y: auto;
} }
.create-form-area.visible { display: block; }
.modal-header { .create-form-area .form-group { margin-bottom: 1rem; }
display: flex; .create-form-area label { font-size: .85rem; color: #a0a0a0; margin-bottom: .4rem; display: block; }
justify-content: space-between; .create-form-area input[type="text"] {
align-items: center; width: 100%;
padding: 1rem; padding: .6rem .85rem;
border-bottom: 1px solid #0f3460; border: 1px solid #0f3460;
border-radius: 6px;
background: #0f1c33;
color: #e0e0e0;
font-size: .9rem;
} }
.create-form-area input:focus {
outline: none;
border-color: #667eea;
}
.form-actions { display: flex; gap: .6rem; margin-top: .5rem; }
.modal-header h3 { /* new token reveal */
margin: 0; .new-token-reveal {
display: none;
background: rgba(74,222,128,.07);
border: 1px solid rgba(74,222,128,.3);
border-radius: 8px;
padding: 1.25rem;
margin-top: 1rem;
} }
.new-token-reveal.visible { display: block; }
.new-token-reveal h4 { color: #4ade80; font-size: .95rem; margin-bottom: .5rem; }
.new-token-reveal .warn { color: #fbbf24; font-size: .83rem; margin: .6rem 0; }
.new-token-reveal .warn i { margin-right: .3rem; }
.close-btn { /* ── API docs section ─────────────────────────────────────── */
background: none; .docs-toggle {
border: none;
color: #e0e0e0;
font-size: 1.5rem;
cursor: pointer; cursor: pointer;
padding: 0; user-select: none;
width: 30px; }
height: 30px; .docs-body { display: none; }
display: flex; .docs-body.visible { display: block; }
align-items: center; .caret { display: inline-block; transition: transform .2s; }
justify-content: center; .docs-toggle.open .caret { transform: rotate(180deg); }
.ep-table { width: 100%; border-collapse: collapse; margin: .75rem 0; }
.ep-table th {
font-size: .75rem;
text-transform: uppercase;
letter-spacing: .05em;
color: #555;
border-bottom: 1px solid #0f3460;
padding: .5rem .75rem;
text-align: left;
} }
.ep-table td {
.modal-body { padding: .6rem .75rem;
padding: 1rem; font-size: .85rem;
color: #c0c0c0;
border-bottom: 1px solid rgba(15,52,96,.4);
} }
.ep-table tr:last-child td { border-bottom: none; }
.form-group { .method-badge {
margin-bottom: 1rem; display: inline-block;
font-size: .7rem;
font-weight: 700;
padding: .2rem .5rem;
border-radius: 4px;
}
.method-GET { background: rgba(74,222,128,.18); color: #4ade80; }
.method-POST { background: rgba(96,165,250,.18); color: #60a5fa; }
.code-block {
background: #0f1c33;
border: 1px solid #0f3460;
border-radius: 6px;
padding: .85rem 1rem;
font-family: monospace;
font-size: .82rem;
color: #c0c0c0;
overflow-x: auto;
margin: .75rem 0 0;
white-space: pre;
} }
</style>
.form-group label { {% if success %}
display: block; <div class="alert alert-success" style="margin-bottom:1.25rem;">{{ success }}</div>
margin-bottom: 0.5rem; {% endif %}
font-weight: 500; {% if error %}
color: #e0e0e0; <div class="alert alert-error" style="margin-bottom:1.25rem;">{{ error }}</div>
{% endif %}
<!-- Page header -->
<div class="page-header">
<div>
<h2><i class="fas fa-key"></i> API Tokens</h2>
<p>Generate tokens to authenticate requests to your personal API endpoints.</p>
</div>
</div>
<!-- Token list -->
<div class="section-card">
<div class="section-header">
<h3><i class="fas fa-list"></i> Your Tokens</h3>
<button class="btn btn-primary btn-sm" onclick="toggleCreateForm()">
<i class="fas fa-plus"></i> New Token
</button>
</div>
<div class="section-body">
<!-- Create form (inline, hidden by default) -->
<div class="create-form-area" id="createFormArea">
<div class="form-group">
<label>Description <span style="color:#555;">(optional)</span></label>
<input type="text" id="tokenDescription" placeholder="e.g. My app, Home server …">
</div>
<div class="form-actions">
<button class="btn btn-primary btn-sm" onclick="submitCreateToken()">Create</button>
<button class="btn btn-secondary btn-sm" onclick="toggleCreateForm()">Cancel</button>
</div>
</div>
<!-- New token reveal -->
<div class="new-token-reveal" id="newTokenReveal">
<h4><i class="fas fa-circle-check"></i> Token created successfully</h4>
<div class="token-value-row" style="margin:.6rem 0;">
<span class="token-value-text" id="newTokenValue"></span>
<button class="copy-btn" onclick="copyNewToken()">Copy</button>
</div>
<p class="warn"><i class="fas fa-triangle-exclamation"></i> Copy this token now — it won't be shown again.</p>
<button class="btn btn-secondary btn-sm" style="margin-top:.5rem;" onclick="dismissReveal()">Done</button>
</div>
<!-- Token list -->
<div class="token-list" id="tokenList" style="margin-top:1rem;"></div>
</div>
</div>
<!-- API Docs (collapsible) -->
<div class="section-card">
<div class="section-header docs-toggle" id="docsToggle" onclick="toggleDocs()">
<h3><i class="fas fa-book-open"></i> How to use your token</h3>
<span style="color:#667eea; font-size:.82rem;">Show / Hide &nbsp;<span class="caret">&#9660;</span></span>
</div>
<div class="docs-body" id="docsBody">
<div class="section-body">
<p style="color:#a0a0a0; font-size:.88rem; margin-bottom:.75rem;">Add the token to every request in the <code style="background:#0f3460; padding:.1rem .35rem; border-radius:3px;">Authorization</code> header:</p>
<div class="code-block">Authorization: Bearer YOUR_API_TOKEN</div>
<p style="color:#a0a0a0; font-size:.88rem; margin-top:1.25rem; margin-bottom:.5rem;">Available endpoints:</p>
<table class="ep-table">
<thead><tr><th>Method</th><th>Endpoint</th><th>Description</th></tr></thead>
<tbody>
<tr><td><span class="method-badge method-GET">GET</span></td> <td><code>/api/u/{{ session.username }}/models</code></td> <td>List your models</td></tr>
<tr><td><span class="method-badge method-GET">GET</span></td> <td><code>/api/u/{{ session.username }}/providers</code></td> <td>List your providers</td></tr>
<tr><td><span class="method-badge method-GET">GET</span></td> <td><code>/api/u/{{ session.username }}/rotations</code></td> <td>List your rotations</td></tr>
<tr><td><span class="method-badge method-GET">GET</span></td> <td><code>/api/u/{{ session.username }}/autoselects</code></td> <td>List your autoselects</td></tr>
<tr><td><span class="method-badge method-POST">POST</span></td><td><code>/api/u/{{ session.username }}/chat/completions</code></td><td>Chat using your configs</td></tr>
<tr><td><span class="method-badge method-GET">GET</span></td> <td><code>/mcp/u/{{ session.username }}/tools</code></td> <td>List MCP tools</td></tr>
<tr><td><span class="method-badge method-POST">POST</span></td><td><code>/mcp/u/{{ session.username }}/tools/call</code></td> <td>Call MCP tools</td></tr>
</tbody>
</table>
<p style="color:#a0a0a0; font-size:.88rem; margin-top:1.25rem; margin-bottom:.5rem;">Example curl commands:</p>
<div class="code-block"># List your models
curl -H "Authorization: Bearer YOUR_TOKEN" \
{{ request.base_url }}api/u/{{ session.username }}/models
# Send a chat request
curl -X POST \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"model":"user-rotation/myrotation","messages":[{"role":"user","content":"Hello"}]}' \
{{ request.base_url }}api/u/{{ session.username }}/chat/completions</div>
</div>
</div>
</div>
<script>
let tokens = {{ user_tokens | tojson }};
/* ── Render tokens ──────────────────────────────────────── */
function renderTokens() {
const list = document.getElementById('tokenList');
if (tokens.length === 0) {
list.innerHTML = `
<div class="empty-state">
<i class="fas fa-key"></i>
<p>No API tokens yet.<br>Create one to start using the API.</p>
</div>`;
return;
}
list.innerHTML = tokens.map(t => `
<div class="token-card" id="token-${t.id}">
<div class="token-card-top">
<div>
<div class="token-name">${escHtml(t.description || 'Unnamed token')}</div>
<div class="token-meta">
<span><i class="fas fa-calendar-plus"></i> Created ${formatDate(t.created_at)}</span>
${t.last_used ? `<span><i class="fas fa-clock"></i> Last used ${formatDate(t.last_used)}</span>` : ''}
</div>
</div>
<div class="token-actions">
<span class="status-pill ${t.is_active ? 'status-active' : 'status-inactive'}">
${t.is_active ? 'Active' : 'Inactive'}
</span>
<button class="btn btn-danger btn-sm" onclick="deleteToken(${t.id})">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<div class="token-value-row">
<span class="token-value-text">${escHtml(t.token.substring(0, 24))}…</span>
<button class="copy-btn" onclick="copyPartialToken(this, ${JSON.stringify(t.token)})">Copy full</button>
</div>
</div>
`).join('');
} }
.form-group input[type="text"] { function escHtml(s) {
width: 100%; const d = document.createElement('div');
padding: 0.5rem; d.textContent = s;
border: 1px solid #0f3460; return d.innerHTML;
border-radius: 4px;
background: #16213e;
color: #e0e0e0;
font-size: 14px;
} }
.form-actions { function formatDate(iso) {
display: flex; if (!iso) return '';
gap: 0.5rem; return new Date(iso).toLocaleString(undefined, {dateStyle:'medium', timeStyle:'short'});
justify-content: flex-end;
margin-top: 1rem;
} }
.warning-text { /* ── Create form ──────────────────────────────────────────── */
color: #ffc107; function toggleCreateForm() {
font-weight: 500; const area = document.getElementById('createFormArea');
const reveal = document.getElementById('newTokenReveal');
area.classList.toggle('visible');
if (!area.classList.contains('visible')) {
document.getElementById('tokenDescription').value = '';
reveal.classList.remove('visible');
}
} }
#created-token { function submitCreateToken() {
background: #0f3460; const desc = document.getElementById('tokenDescription').value.trim();
padding: 0.5rem; const formData = new FormData();
border-radius: 4px; if (desc) formData.append('description', desc);
font-family: monospace;
word-break: break-all; fetch('{{ url_for(request, "/dashboard/user/tokens") }}', {
method: 'POST',
body: formData
})
.then(r => r.ok ? r.json() : r.json().then(d => Promise.reject(d.error || 'Failed')))
.then(data => {
document.getElementById('createFormArea').classList.remove('visible');
document.getElementById('tokenDescription').value = '';
document.getElementById('newTokenValue').textContent = data.token;
document.getElementById('newTokenReveal').classList.add('visible');
tokens.push({
id: data.token_id,
token: data.token,
description: desc || null,
created_at: new Date().toISOString(),
last_used: null,
is_active: true
});
renderTokens();
})
.catch(err => showAlert('Error: ' + err, 'Error', '❌', 'danger'));
} }
/* API Endpoints Table */ function dismissReveal() {
.endpoints-table { document.getElementById('newTokenReveal').classList.remove('visible');
width: 100%;
margin: 1rem 0;
border-collapse: collapse;
} }
.endpoints-table th, /* ── Copy helpers ─────────────────────────────────────────── */
.endpoints-table td { function copyNewToken() {
padding: 0.75rem; const token = document.getElementById('newTokenValue').textContent;
text-align: left; copyToClipboard(token, document.querySelector('#newTokenReveal .copy-btn'));
border-bottom: 1px solid #333;
} }
.endpoints-table th { function copyPartialToken(btn, fullToken) {
background: #1a1a2e; copyToClipboard(fullToken, btn);
font-weight: bold;
} }
.endpoints-table .method { function copyToClipboard(text, btn) {
display: inline-block; navigator.clipboard.writeText(text).then(() => {
padding: 0.2rem 0.5rem; if (btn) {
border-radius: 3px; const orig = btn.textContent;
font-weight: bold; btn.textContent = 'Copied!';
font-size: 0.75rem; btn.classList.add('copied');
setTimeout(() => { btn.textContent = orig; btn.classList.remove('copied'); }, 1800);
}
}).catch(() => {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0;';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch(e) {}
document.body.removeChild(ta);
});
} }
.endpoints-table .method.GET { /* ── Delete ───────────────────────────────────────────────── */
background: #28a745; async function deleteToken(id) {
color: white; const ok = await showDangerConfirm('Delete this API token? This will immediately revoke access and cannot be undone.', 'Delete Token');
if (!ok) return;
fetch('{{ url_for(request, "/dashboard/user/tokens") }}/' + id, {
method: 'DELETE',
headers: {'Content-Type': 'application/json'}
})
.then(r => {
if (r.ok) {
tokens = tokens.filter(t => t.id !== id);
renderTokens();
} else {
return r.json().then(d => Promise.reject(d.error || 'Failed'));
}
})
.catch(err => showAlert('Error: ' + err, 'Error', '❌', 'danger'));
} }
.endpoints-table .method.POST { /* ── Docs toggle ──────────────────────────────────────────── */
background: #007bff; function toggleDocs() {
color: white; document.getElementById('docsToggle').classList.toggle('open');
document.getElementById('docsBody').classList.toggle('visible');
} }
</style>
{% endblock %} renderTokens();
\ No newline at end of file </script>
{% endblock %}
...@@ -688,7 +688,7 @@ function clearSelection() { ...@@ -688,7 +688,7 @@ function clearSelection() {
updateBulkActionsVisibility(); updateBulkActionsVisibility();
} }
function performBulkAction(action, description, destructive = false, extraData = null) { async function performBulkAction(action, description, destructive = false, extraData = null) {
const selectedIds = Array.from(document.querySelectorAll('.user-checkbox:checked')).map(cb => parseInt(cb.value)); const selectedIds = Array.from(document.querySelectorAll('.user-checkbox:checked')).map(cb => parseInt(cb.value));
if (selectedIds.length === 0) { if (selectedIds.length === 0) {
...@@ -696,8 +696,9 @@ function performBulkAction(action, description, destructive = false, extraData = ...@@ -696,8 +696,9 @@ function performBulkAction(action, description, destructive = false, extraData =
return; return;
} }
if (destructive && !confirm(`Are you sure you want to ${description}? This action cannot be undone.`)) { if (destructive) {
return; const ok = await showDangerConfirm(`Are you sure you want to ${description}? This action cannot be undone.`, 'Confirm Action');
if (!ok) return;
} }
// Show loading state // Show loading state
...@@ -754,9 +755,10 @@ function closeEditModal() { ...@@ -754,9 +755,10 @@ function closeEditModal() {
document.getElementById('edit-modal').style.display = 'none'; document.getElementById('edit-modal').style.display = 'none';
} }
function toggleUserStatus(userId, currentStatus) { async function toggleUserStatus(userId, currentStatus) {
const action = currentStatus ? 'disable' : 'enable'; const action = currentStatus ? 'disable' : 'enable';
if (confirm('Are you sure you want to ' + action + ' this user?')) { const ok = await showConfirm('Are you sure you want to ' + action + ' this user?', 'Confirm');
if (ok) {
fetch(baseUrl + userId + '/toggle', { fetch(baseUrl + userId + '/toggle', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'} headers: {'Content-Type': 'application/json'}
...@@ -766,17 +768,18 @@ function toggleUserStatus(userId, currentStatus) { ...@@ -766,17 +768,18 @@ function toggleUserStatus(userId, currentStatus) {
if (data.success) { if (data.success) {
location.reload(); location.reload();
} else { } else {
alert(data.error || 'Failed to toggle user status'); showAlert(data.error || 'Failed to toggle user status', 'Error', '❌', 'danger');
} }
}) })
.catch(error => { .catch(error => {
alert('Error: ' + error); showAlert('Error: ' + error, 'Error', '❌', 'danger');
}); });
} }
} }
function deleteUser(userId, username) { async function deleteUser(userId, username) {
if (confirm('Are you sure you want to delete user "' + username + '"? This action cannot be undone.')) { const ok = await showDangerConfirm('Are you sure you want to delete user "' + username + '"? This action cannot be undone.', 'Delete User');
if (ok) {
fetch(baseUrl + userId + '/delete', { fetch(baseUrl + userId + '/delete', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'} headers: {'Content-Type': 'application/json'}
...@@ -786,11 +789,11 @@ function deleteUser(userId, username) { ...@@ -786,11 +789,11 @@ function deleteUser(userId, username) {
if (data.success) { if (data.success) {
location.reload(); location.reload();
} else { } else {
alert(data.error || 'Failed to delete user'); showAlert(data.error || 'Failed to delete user', 'Error', '❌', 'danger');
} }
}) })
.catch(error => { .catch(error => {
alert('Error: ' + error); showAlert('Error: ' + error, 'Error', '❌', 'danger');
}); });
} }
} }
......
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 15px;"> <div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 15px;">
<div> <div>
<div style="color: #a0a0a0; font-size: 14px; margin-bottom: 6px;">Available Balance</div> <div style="color: #a0a0a0; font-size: 14px; margin-bottom: 6px;">Available Balance</div>
<div style="font-size: 42px; font-weight: bold; color: #4ade80;">${{ "%.2f"|format(wallet.balance|float) }}</div> <div style="font-size: 42px; font-weight: bold; color: #4ade80;">{{ currency_symbol }}{{ "%.2f"|format(wallet.balance|float) }}</div>
<div style="color: #a0a0a0; font-size: 13px; margin-top: 6px;"> <div style="color: #a0a0a0; font-size: 13px; margin-top: 6px;">
Currency: <span style="color: #e0e0e0;">{{ wallet.currency_code }}</span> Currency: <span style="color: #e0e0e0;">{{ wallet.currency_code }}</span>
&nbsp;·&nbsp; Wallet ID: <span style="color: #e0e0e0; font-family: monospace;">{{ wallet.id }}</span> &nbsp;·&nbsp; Wallet ID: <span style="color: #e0e0e0; font-family: monospace;">{{ wallet.id }}</span>
...@@ -56,7 +56,7 @@ ...@@ -56,7 +56,7 @@
{% for amt in [10, 15, 20, 50, 100] %} {% for amt in [10, 15, 20, 50, 100] %}
<button type="button" class="amount-btn" data-amount="{{ amt }}" <button type="button" class="amount-btn" data-amount="{{ amt }}"
style="background: #1a1a2e; border: 1px solid #4a9eff; color: #4a9eff; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 14px; transition: all .15s;"> style="background: #1a1a2e; border: 1px solid #4a9eff; color: #4a9eff; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 14px; transition: all .15s;">
${{ amt }} {{ currency_symbol }}{{ amt }}
</button> </button>
{% endfor %} {% endfor %}
</div> </div>
...@@ -65,7 +65,7 @@ ...@@ -65,7 +65,7 @@
<label style="display: block; color: #a0a0a0; font-size: 13px; margin-bottom: 6px;">Custom amount</label> <label style="display: block; color: #a0a0a0; font-size: 13px; margin-bottom: 6px;">Custom amount</label>
<input type="number" id="custom-amount" <input type="number" id="custom-amount"
style="width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #0f3460; border-radius: 6px; color: #e0e0e0; font-size: 15px;" style="width: 100%; padding: 10px; background: #1a1a2e; border: 1px solid #0f3460; border-radius: 6px; color: #e0e0e0; font-size: 15px;"
placeholder="Enter amount ($5 – $500)" step="0.01" min="5" max="500"> placeholder="Enter amount ({{ currency_symbol }}5 – {{ currency_symbol }}500)" step="0.01" min="5" max="500">
</div> </div>
<div style="display: flex; flex-direction: column; gap: 10px; {% if crypto_gateways %}margin-bottom: 16px;{% endif %}"> <div style="display: flex; flex-direction: column; gap: 10px; {% if crypto_gateways %}margin-bottom: 16px;{% endif %}">
{% for name in fiat_gateways %} {% for name in fiat_gateways %}
...@@ -89,33 +89,23 @@ ...@@ -89,33 +89,23 @@
<hr style="border-color: #0f3460; margin: 4px 0 14px 0;"> <hr style="border-color: #0f3460; margin: 4px 0 14px 0;">
{% endif %} {% endif %}
<div style="color: #a0a0a0; font-size: 13px; margin-bottom: 10px;"> <div style="color: #a0a0a0; font-size: 13px; margin-bottom: 10px;">
<i class="fas fa-coins me-1"></i>Crypto deposit — send to address below <i class="fas fa-coins me-1"></i>Crypto deposit
</div> </div>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
{% for name in crypto_gateways %} {% for name in crypto_gateways %}
{% set cfg = enabled_gateways[name] %} {% set cfg = enabled_gateways[name] %}
{% set address = cfg.get('wallet_address') or cfg.get('address') or '' %} {% set address = cfg.get('wallet_address') or cfg.get('address') or '' %}
<div style="background: #1a1a2e; border: 1px solid #0f3460; border-radius: 6px; padding: 12px; margin-bottom: 10px;"> <button onclick="openCryptoModal('{{ name }}','{{ address }}')"
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;"> style="background:#1a1a2e; border:1px solid #0f3460; color:#e0e0e0; padding:8px 16px; border-radius:6px; cursor:pointer; font-size:13px; font-weight:600; display:flex; align-items:center; gap:6px; transition:border-color .15s;"
<span style="color: #e0e0e0; font-weight: 600;"> onmouseover="this.style.borderColor='#4a9eff'" onmouseout="this.style.borderColor='#0f3460'">
{% if name == 'bitcoin' %}<i class="fab fa-bitcoin me-1" style="color:#f7931a;"></i> {% if name == 'bitcoin' %}<i class="fab fa-bitcoin" style="color:#f7931a;"></i>
{% elif name == 'ethereum' %}<i class="fab fa-ethereum me-1" style="color:#627eea;"></i> {% elif name == 'ethereum' %}<i class="fab fa-ethereum" style="color:#627eea;"></i>
{% else %}<i class="fas fa-coins me-1" style="color:#26a17b;"></i> {% else %}<i class="fas fa-coins" style="color:#26a17b;"></i>
{% endif %} {% endif %}
{{ name | upper }} {{ name | upper }}
</span> </button>
<button onclick="copyAddress('{{ address }}', this)"
style="background: #4a9eff; border: none; color: white; padding: 5px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;">
<i class="fas fa-copy me-1"></i>Copy
</button>
</div>
<div style="font-family: monospace; font-size: 12px; color: #a0a0a0; word-break: break-all; background: #0f1631; padding: 8px; border-radius: 4px;">
{{ address or 'Address not configured' }}
</div>
<div style="color: #6c757d; font-size: 11px; margin-top: 6px;">
Send {{ name | upper }} to this address. Balance will be credited after on-chain confirmation.
</div>
</div>
{% endfor %} {% endfor %}
</div>
{% endif %} {% endif %}
{% endif %} {% endif %}
...@@ -144,7 +134,7 @@ ...@@ -144,7 +134,7 @@
<div style="margin-bottom: 14px;"> <div style="margin-bottom: 14px;">
<label style="display: block; color: #a0a0a0; font-size: 13px; margin-bottom: 6px;">Top-up amount</label> <label style="display: block; color: #a0a0a0; font-size: 13px; margin-bottom: 6px;">Top-up amount</label>
<div style="position: relative;"> <div style="position: relative;">
<span style="position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: #4ade80; font-weight: bold;">$</span> <span style="position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: #4ade80; font-weight: bold;">{{ currency_symbol }}</span>
<input type="number" id="auto-topup-amount" <input type="number" id="auto-topup-amount"
style="width: 100%; padding: 10px 10px 10px 26px; background: #1a1a2e; border: 1px solid #0f3460; border-radius: 6px; color: #e0e0e0; font-size: 15px;" style="width: 100%; padding: 10px 10px 10px 26px; background: #1a1a2e; border: 1px solid #0f3460; border-radius: 6px; color: #e0e0e0; font-size: 15px;"
value="{{ wallet.auto_topup_amount or '' }}" step="0.01" min="10"> value="{{ wallet.auto_topup_amount or '' }}" step="0.01" min="10">
...@@ -153,7 +143,7 @@ ...@@ -153,7 +143,7 @@
<div style="margin-bottom: 14px;"> <div style="margin-bottom: 14px;">
<label style="display: block; color: #a0a0a0; font-size: 13px; margin-bottom: 6px;">Trigger when balance falls below</label> <label style="display: block; color: #a0a0a0; font-size: 13px; margin-bottom: 6px;">Trigger when balance falls below</label>
<div style="position: relative;"> <div style="position: relative;">
<span style="position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: #ffc107; font-weight: bold;">$</span> <span style="position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: #ffc107; font-weight: bold;">{{ currency_symbol }}</span>
<input type="number" id="auto-topup-threshold" <input type="number" id="auto-topup-threshold"
style="width: 100%; padding: 10px 10px 10px 26px; background: #1a1a2e; border: 1px solid #0f3460; border-radius: 6px; color: #e0e0e0; font-size: 15px;" style="width: 100%; padding: 10px 10px 10px 26px; background: #1a1a2e; border: 1px solid #0f3460; border-radius: 6px; color: #e0e0e0; font-size: 15px;"
value="{{ wallet.auto_topup_threshold or '' }}" step="0.01" min="1"> value="{{ wallet.auto_topup_threshold or '' }}" step="0.01" min="1">
...@@ -212,6 +202,27 @@ ...@@ -212,6 +202,27 @@
</table> </table>
</div> </div>
</div> </div>
<!-- Crypto deposit modal -->
<div class="ui-modal-backdrop" id="cryptoModal" style="z-index:1100;">
<div class="ui-modal-box" style="max-width:420px;">
<div class="ui-modal-header">
<h3 id="cryptoModalTitle">Deposit</h3>
<button class="close-modal" onclick="closeCryptoModal()">&times;</button>
</div>
<div style="text-align:center; padding: 0 0 16px;">
<div id="cryptoQR" style="margin: 0 auto 16px; width:200px; height:200px; background:#fff; border-radius:8px; display:flex; align-items:center; justify-content:center;">
<i class="fas fa-spinner fa-spin fa-2x" style="color:#0f3460;"></i>
</div>
<div style="color:#a0a0a0; font-size:12px; margin-bottom:6px;">Send to this address:</div>
<div id="cryptoAddress" style="font-family:monospace; font-size:12px; color:#e0e0e0; word-break:break-all; background:#0f1631; padding:10px; border-radius:6px; margin-bottom:12px;"></div>
<button onclick="copyCryptoAddress()"
style="background:#4a9eff; border:none; color:white; padding:8px 20px; border-radius:6px; cursor:pointer; font-size:13px; font-weight:600;">
<i class="fas fa-copy me-1"></i>Copy Address
</button>
<div style="color:#6c757d; font-size:11px; margin-top:12px;">Balance credited after on-chain confirmation.</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
...@@ -258,7 +269,7 @@ document.addEventListener('DOMContentLoaded', function () { ...@@ -258,7 +269,7 @@ document.addEventListener('DOMContentLoaded', function () {
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
const amount = getAmount(); const amount = getAmount();
if (!amount || amount < 5 || amount > 500) { if (!amount || amount < 5 || amount > 500) {
alert('Please select or enter an amount between $5 and $500.'); showAlert('Please select or enter an amount between {{ currency_symbol }}5 and {{ currency_symbol }}500.', 'Invalid Amount', '⚠️', 'warn');
return; return;
} }
const method = this.dataset.method; const method = this.dataset.method;
...@@ -279,13 +290,13 @@ document.addEventListener('DOMContentLoaded', function () { ...@@ -279,13 +290,13 @@ document.addEventListener('DOMContentLoaded', function () {
} else { } else {
this.disabled = false; this.disabled = false;
this.innerHTML = orig; this.innerHTML = orig;
alert(data.error || 'Failed to initiate checkout.'); showAlert(data.error || 'Failed to initiate checkout.', 'Error', '❌', 'danger');
} }
}) })
.catch(() => { .catch(() => {
this.disabled = false; this.disabled = false;
this.innerHTML = orig; this.innerHTML = orig;
alert('Network error. Please try again.'); showAlert('Network error. Please try again.', 'Error', '❌', 'danger');
}); });
}); });
}); });
...@@ -330,10 +341,10 @@ document.addEventListener('DOMContentLoaded', function () { ...@@ -330,10 +341,10 @@ document.addEventListener('DOMContentLoaded', function () {
this.textContent = '✓ Saved'; this.textContent = '✓ Saved';
setTimeout(() => { this.innerHTML = '<i class="fas fa-save me-2"></i>Save Settings'; }, 2000); setTimeout(() => { this.innerHTML = '<i class="fas fa-save me-2"></i>Save Settings'; }, 2000);
} else { } else {
alert(data.error || 'Failed to save settings.'); showAlert(data.error || 'Failed to save settings.', 'Error', '❌', 'danger');
} }
}) })
.catch(() => alert('Network error. Please try again.')); .catch(() => showAlert('Network error. Please try again.', 'Error', '❌', 'danger'));
}); });
// ── Transaction history ───────────────────────────────────── // ── Transaction history ─────────────────────────────────────
...@@ -407,7 +418,44 @@ function copyAddress(addr, btn) { ...@@ -407,7 +418,44 @@ function copyAddress(addr, btn) {
btn.innerHTML = '<i class="fas fa-check me-1"></i>Copied!'; btn.innerHTML = '<i class="fas fa-check me-1"></i>Copied!';
btn.style.background = '#28a745'; btn.style.background = '#28a745';
setTimeout(() => { btn.innerHTML = orig; btn.style.background = ''; }, 2000); setTimeout(() => { btn.innerHTML = orig; btn.style.background = ''; }, 2000);
}).catch(() => alert('Copy failed — please copy the address manually.')); }).catch(() => showAlert('Copy failed — please copy the address manually.', 'Copy Failed', '⚠️', 'warn'));
} }
let _cryptoAddr = '';
function openCryptoModal(name, address) {
const icons = { bitcoin: '₿ Bitcoin (BTC)', ethereum: 'Ξ Ethereum (ETH)', usdt: '₮ USDT', usdc: '◎ USDC' };
document.getElementById('cryptoModalTitle').textContent = icons[name] || name.toUpperCase();
document.getElementById('cryptoAddress').textContent = address || 'Address not configured';
_cryptoAddr = address;
const qrEl = document.getElementById('cryptoQR');
qrEl.innerHTML = '<i class="fas fa-spinner fa-spin fa-2x" style="color:#0f3460;"></i>';
document.getElementById('cryptoModal').classList.add('active');
if (address) {
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(address)}&bgcolor=ffffff&color=000000&margin=8`;
const img = new Image();
img.onload = () => { qrEl.innerHTML = ''; qrEl.appendChild(img); };
img.onerror = () => { qrEl.innerHTML = '<span style="color:#888;font-size:12px;">QR unavailable</span>'; };
img.src = qrUrl;
img.style.cssText = 'width:200px;height:200px;border-radius:8px;';
}
}
function closeCryptoModal() {
document.getElementById('cryptoModal').classList.remove('active');
}
function copyCryptoAddress() {
navigator.clipboard.writeText(_cryptoAddr)
.then(() => showAlert('Address copied to clipboard!', 'Copied', '✅', 'info'))
.catch(() => showAlert('Copy failed — please copy manually.', 'Copy Failed', '⚠️', 'warn'));
}
document.getElementById('cryptoModal').addEventListener('click', e => {
if (e.target === document.getElementById('cryptoModal')) closeCryptoModal();
});
</script> </script>
{% endblock %} {% endblock %}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment