Revamp the dashboard interface

parent 59a08356
......@@ -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.
## 🚀 Support AISBF - Your Donations Matter!
---
The project includes multiple donation options to support its development:
## 🌐 Try AISBF Now — No Installation Required!
### Ethereum Donation
ETH to `0xdA6dAb526515b5cb556d20269207D43fcc760E51`
> **[➡️ Launch AISBF at https://aisbf.cloud](https://aisbf.cloud)**
>
> 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
https://paypal.me/nexlab
### 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!
Also available via TOR for privacy-first access:
[http://aisbfity4ud6nsht53tsh2iauaur2e4dah2gplcprnikyjpkg72vfjad.onion](http://aisbfity4ud6nsht53tsh2iauaur2e4dah2gplcprnikyjpkg72vfjad.onion)
![AISBF Dashboard](https://git.nexlab.net/nexlab/aisbf/raw/master/screenshot.png)
---
## Key Features
- **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:
- **[🔌 API Reference](DOCUMENTATION.md#api-endpoints)** - Complete API documentation
- **[🛠️ 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
Stefy Lanza <stefy@nexlab.net>
......
......@@ -2291,7 +2291,11 @@ async def dashboard_analytics(
user_filter_int = current_user_id
# 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
available_providers = list(config.providers.keys()) if config else []
......@@ -2379,7 +2383,8 @@ async def dashboard_analytics(
"selected_rotation": rotation_filter,
"selected_autoselect": autoselect_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:
logger.info(f"User authenticated: username={username}, email={user.get('email')}, user_id={user['id']}")
request.session['logged_in'] = True
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['role'] = user['role']
request.session['user_id'] = user['id']
......@@ -3203,8 +3209,9 @@ async def dashboard_profile_save(request: Request, username: str = Form(...), di
try:
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['display_name'] = display_name or ''
return RedirectResponse(url=url_for(request, "/dashboard/profile?success=Profile updated successfully"), status_code=303)
except Exception as e:
......@@ -4031,7 +4038,8 @@ async def dashboard_index(request: Request):
"subscription": subscription,
"current_tier": current_tier,
"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):
name="dashboard/admin_payment_settings.html",
context={
"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):
all_gateways = db.get_payment_gateway_settings()
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,
name="dashboard/wallet.html",
context={
"request": request,
"wallet": wallet,
"enabled_gateways": enabled_gateways,
})
"currency_symbol": db.get_currency_settings().get('currency_symbol', '$'),
}
)
except ImportError:
return HTMLResponse("Wallet functionality not available", status_code=503)
except Exception as e:
......@@ -8329,7 +8343,8 @@ async def dashboard_billing(request: Request):
"payment_methods": payment_methods,
"transactions": transactions,
"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/>.
});
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") }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
})
.then(response => response.json())
.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);
})
.catch(error => {
alert('Error restarting server: ' + error);
showAlert('Error restarting server: ' + error, 'Error', '❌', 'danger');
});
});
}
}
// Snake Game Easter Egg
......@@ -418,9 +419,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
function gameOver() {
clearInterval(gameLoop);
alert('Game Over! Score: ' + score + '\nPress OK to play again.');
showAlert('Game Over! Score: ' + score, 'Game Over 🐍').then(() => {
startScreen.classList.remove('hidden');
scoreDisplay.style.display = 'none';
});
}
// Keyboard controls
......@@ -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>
{% if request.session.logged_in %}
<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/about') }}" class="btn btn-secondary">About</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/>.
box-shadow: 0 4px 15px rgba(245, 87, 108, 0.4);
}
.donate-modal {
/* ── Shared modal backdrop ── */
.donate-modal, .ui-modal-backdrop {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
top: 0; left: 0;
width: 100%; height: 100%;
background: rgba(0,0,0,0.7);
z-index: 1000;
align-items: center;
justify-content: center;
}
.donate-modal.active, .ui-modal-backdrop.active { display: flex; }
.donate-modal.active {
display: flex;
}
.donate-modal-content {
.donate-modal-content, .ui-modal-box {
background: #16213e;
padding: 30px;
border-radius: 12px;
......@@ -600,14 +601,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
border: 1px solid #0f3460;
}
.donate-modal-header {
.donate-modal-header, .ui-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.donate-modal-header h3 {
.donate-modal-header h3, .ui-modal-header h3 {
color: #f5576c;
margin: 0;
}
......@@ -620,6 +621,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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 {
background: #0f3460;
padding: 12px;
......@@ -909,6 +924,100 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
}
</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 %}
</body>
</html>
......@@ -140,7 +140,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (data.success) {
window.location.href = '/dashboard/billing?success=' + encodeURIComponent(data.message);
} else {
alert('Error: ' + data.error);
showAlert('Error: ' + data.error, 'Error', '❌', 'danger');
}
});
});
......@@ -165,7 +165,7 @@ document.addEventListener('DOMContentLoaded', function() {
const stripeKey = '{{ stripe_publishable_key }}';
if (!stripeKey) {
alert('Stripe is not configured. Please contact the administrator.');
showAlert('Stripe is not configured. Please contact the administrator.', 'Not Configured', '⚠️', 'warn');
return;
}
......
......@@ -19,6 +19,49 @@
</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 -->
<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;">
......@@ -638,9 +681,8 @@ async function saveEncryptionKey() {
return;
}
if (!confirm('WARNING: Setting or changing the encryption key will affect all encrypted data. Are you sure?')) {
return;
}
const ok = await showDangerConfirm('WARNING: Setting or changing the encryption key will affect all encrypted data. Are you sure?', 'Change Encryption Key');
if (!ok) return;
try {
const response = await fetch('{{ url_for(request, "/api/admin/settings/encryption-key") }}', {
......@@ -678,7 +720,7 @@ async function loadSystemStatus() {
}
// 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('failedPayments').textContent = data.failed_payments || 0;
} catch (error) {
......@@ -1062,6 +1104,7 @@ document.addEventListener('DOMContentLoaded', function() {
loadConfiguration();
loadPaymentGateways();
loadCryptoPrices();
loadCurrencySettings();
// Auto-refresh crypto prices every 60 seconds
setInterval(loadCryptoPrices, 60000);
......@@ -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
function refreshCryptoPrices() {
['Btc', 'Eth', 'Usdt', 'Usdc'].forEach(crypto => {
......
......@@ -69,49 +69,6 @@
</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 -->
<div class="modal fade" id="tierModal" tabindex="-1" aria-labelledby="tierModalLabel" aria-hidden="true" style="display: none;">
......@@ -229,7 +186,6 @@
<script>
document.addEventListener('DOMContentLoaded', function() {
try {
loadCurrencySettings();
} catch (error) {
console.error('Error initializing page:', error);
}
......@@ -403,15 +359,15 @@ function closeTierModal() {
document.getElementById('tierModal').style.display = 'none';
}
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.')) {
async function deleteTier(tierId) {
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, {
method: 'DELETE'
})
.then(response => response.json())
.then(result => {
showToast('Tier deleted successfully', 'success');
// Reload page to reflect changes
window.location.reload();
})
.catch(error => {
......@@ -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) {
......
......@@ -99,7 +99,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</span>
{% if date_range_usage %}
<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>
{% endif %}
</div>
......@@ -494,7 +494,7 @@ document.getElementById('timeRangeSelect').addEventListener('change', function()
{% if total_requests > 0 %}
{% set weighted_sum = namespace(value=0) %}
{% 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 %}
{% 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 %}
......@@ -521,7 +521,7 @@ document.getElementById('timeRangeSelect').addEventListener('change', function()
<h4 style="font-size: 14px; margin-bottom: 10px;">
{% if from_date or to_date %}Selected Period Cost{% else %}Today's Estimated Cost{% endif %}
</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 %}
<small style="color: rgba(255,255,255,0.8);">
{{ cost_overview.date_range.start[:10] }} to {{ cost_overview.date_range.end[:10] }}
......
......@@ -315,14 +315,14 @@ function renderAutoselectModels(autoselectKey) {
});
}
function addAutoselect() {
const key = prompt('Enter autoselect key (e.g., "autoselect", "smart-select"):');
async function addAutoselect() {
const key = await showPrompt('Enter autoselect key (e.g., "autoselect", "smart-select"):', '', 'e.g. autoselect', 'Add Autoselect');
if (!key) {
return;
}
if (autoselectConfig[key]) {
alert('Autoselect key already exists');
showAlert('Autoselect key already exists', 'Duplicate Key', '⚠️', 'warn');
return;
}
......@@ -338,8 +338,8 @@ function addAutoselect() {
renderAutoselectList();
}
function removeAutoselect(key) {
if (confirm(`Remove autoselect "${key}"?`)) {
async function removeAutoselect(key) {
if (await showDangerConfirm(`Remove autoselect "${key}"?`, 'Remove Autoselect')) {
delete autoselectConfig[key];
expandedAutoselects.delete(key);
renderAutoselectList();
......@@ -375,8 +375,8 @@ function addAutoselectModel(autoselectKey) {
renderAutoselectModels(autoselectKey);
}
function removeAutoselectModel(autoselectKey, index) {
if (confirm('Remove this model?')) {
async function removeAutoselectModel(autoselectKey, index) {
if (await showDangerConfirm('Remove this model?', 'Remove Model')) {
autoselectConfig[autoselectKey].available_models.splice(index, 1);
renderAutoselectModels(autoselectKey);
}
......@@ -399,10 +399,10 @@ async function saveAutoselect() {
if (response.ok) {
window.location.href = autoselectData.successUrl;
} else {
alert('Error saving configuration');
showAlert('Error saving configuration', 'Error', '❌', 'danger');
}
} catch (error) {
alert('Error: ' + error.message);
showAlert('Error: ' + error.message, 'Error', '❌', 'danger');
}
}
......
......@@ -8,7 +8,7 @@
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 15px;">
<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;">
All subscription renewals and payments are automatically charged from your wallet first.
</div>
......@@ -315,11 +315,12 @@ document.addEventListener('DOMContentLoaded', function() {
if (!methodId) {
console.error('No method ID found');
alert('Error: Payment method ID not found');
showAlert('Error: Payment method ID not found', 'Error', '❌', 'danger');
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}`);
fetch(`/dashboard/billing/payment-methods/${methodId}`, {
......@@ -335,17 +336,16 @@ document.addEventListener('DOMContentLoaded', function() {
.then(data => {
console.log('Response data:', data);
if (data.success) {
alert('Payment method deleted successfully');
window.location.reload();
showAlert('Payment method deleted successfully', 'Done', '✅', 'info').then(() => window.location.reload());
} else {
alert('Error: ' + (data.error || 'Failed to delete payment method'));
showAlert('Error: ' + (data.error || 'Failed to delete payment method'), 'Error', '❌', 'danger');
}
})
.catch(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() {
if (!methodId) {
console.error('No method ID found');
alert('Error: Payment method ID not found');
showAlert('Error: Payment method ID not found', 'Error', '❌', 'danger');
return;
}
......@@ -382,15 +382,14 @@ document.addEventListener('DOMContentLoaded', function() {
.then(data => {
console.log('Response data:', data);
if (data.success) {
alert('Payment method set as default');
window.location.reload();
showAlert('Payment method set as default', 'Done', '✅', 'info').then(() => window.location.reload());
} 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 => {
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 @@
</style>
<script>
function confirmDeletion() {
async function confirmDeletion() {
const confirmation = document.getElementById('confirmation').value;
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 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>
{% endblock %}
......@@ -19,55 +19,94 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% block title %}Overview - AISBF Dashboard{% endblock %}
{% 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;">
<div style="background: #3498db; color: white; padding: 20px; border-radius: 8px;">
<h3 style="font-size: 16px; margin-bottom: 10px;">Providers</h3>
<p style="font-size: 32px; font-weight: bold;">{{ providers_count }}</p>
.info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; margin-bottom: 32px; }
.info-card { background: #1a1a2e; border: 1px solid #0f3460; border-radius: 10px; padding: 20px; }
.info-card h4 { font-size: .8em; text-transform: uppercase; letter-spacing: .08em; color: #a0a0a0; margin-bottom: 14px; }
.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 style="background: #2ecc71; color: white; padding: 20px; border-radius: 8px;">
<h3 style="font-size: 16px; margin-bottom: 10px;">Rotations</h3>
<p style="font-size: 32px; font-weight: bold;">{{ rotations_count }}</p>
</div>
<div style="background: #e74c3c; color: white; padding: 20px; border-radius: 8px;">
<h3 style="font-size: 16px; margin-bottom: 10px;">Autoselect</h3>
<p style="font-size: 32px; font-weight: bold;">{{ autoselect_count }}</p>
<div class="stat-card">
<div class="stat-icon red"><i class="fas fa-brain" style="color:#e94560;"></i></div>
<div>
<div class="stat-value" style="color:#e94560;">{{ autoselect_count }}</div>
<div class="stat-label">Autoselects</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon purple"><i class="fas fa-users" style="color:#9b59b6;"></i></div>
<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>
<h3 style="margin-bottom: 15px;">Server Information</h3>
<table>
<tr>
<th>Setting</th>
<th>Value</th>
</tr>
<tr>
<td>Host</td>
<td>{{ server_config.host }}</td>
</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>
<!-- Info cards -->
<div class="info-grid">
<div class="info-card">
<h4><i class="fas fa-server"></i> &nbsp;Server</h4>
<div class="info-row"><span>Host</span><span class="val">{{ server_config.host }}</span></div>
<div class="info-row"><span>Port</span><span class="val">{{ server_config.port }}</span></div>
<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>
<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>
</div>
<h3 style="margin-top: 30px; margin-bottom: 15px;">Quick Actions</h3>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<a href="{{ url_for(request, '/dashboard/providers') }}" class="btn">Manage Providers</a>
<a href="{{ url_for(request, '/dashboard/rotations') }}" class="btn">Manage Rotations</a>
<a href="{{ url_for(request, '/dashboard/autoselect') }}" class="btn">Manage Autoselect</a>
<a href="{{ url_for(request, '/dashboard/prompts') }}" class="btn">Manage Prompts</a>
<a href="{{ url_for(request, '/dashboard/rate-limits') }}" class="btn">Rate Limits</a>
<a href="{{ url_for(request, '/dashboard/response-cache') }}" class="btn">Response Cache</a>
<a href="{{ url_for(request, '/dashboard/settings') }}" class="btn btn-secondary">Server Settings</a>
<div class="info-card">
<h4><i class="fas fa-bolt"></i> &nbsp;Quick Actions</h4>
<div class="quick-actions" style="margin-top:4px;">
<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/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/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/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/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/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>
{% endblock %}
......@@ -143,7 +143,7 @@ function openOAuthPopup(url) {
window.removeEventListener('message', messageListener);
// Show error
alert(event.data.error || 'Authentication failed');
showAlert(event.data.error || 'Authentication failed', 'Authentication Error', '❌', 'danger');
}
};
......
......@@ -216,8 +216,9 @@
{% block extra_js %}
<script>
function subscribeToTier(tierId) {
if (confirm('Are you sure you want to upgrade to this tier?')) {
async function subscribeToTier(tierId) {
const ok = await showConfirm('Are you sure you want to upgrade to this tier?', 'Upgrade Plan');
if (ok) {
fetch(url_for(`/api/subscribe/${tierId}`), {
method: 'POST',
headers: {
......@@ -241,8 +242,9 @@ function subscribeToTier(tierId) {
}
}
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.')) {
async function downgradeToFree() {
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'), {
method: 'POST',
headers: {
......
......@@ -72,9 +72,10 @@ function switchPrompt() {
}
}
function resetPrompt() {
async function resetPrompt() {
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, {
method: 'POST',
headers: {
......
......@@ -134,7 +134,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% block extra_js %}
<script>
let providersData = {{ providers_json | safe }};
let providersData = {{ providers_json | replace("</script>", "<\\/script>") | safe }};
let expandedProviders = new Set();
// Chunk size: 512KB chunks for maximum compatibility with restrictive proxies
......@@ -1603,8 +1603,8 @@ async function pollKiloAuth(providerKey, deviceCode) {
// This function is no longer needed as polling is handled in authenticateKilo
}
function removeProvider(key) {
if (confirm(`Remove provider "${key}"?`)) {
async function removeProvider(key) {
if (await showDangerConfirm(`Remove provider "${key}"?`, 'Remove Provider')) {
delete providersData[key];
expandedProviders.delete(key);
renderProvidersList();
......@@ -1684,8 +1684,8 @@ function addModel(key) {
renderModels(key);
}
function removeModel(providerKey, index) {
if (confirm('Remove this model?')) {
async function removeModel(providerKey, index) {
if (await showDangerConfirm('Remove this model?', 'Remove Model')) {
providersData[providerKey].models.splice(index, 1);
renderModels(providerKey);
}
......@@ -1696,12 +1696,12 @@ async function confirmAddProvider() {
const type = document.getElementById('new-provider-type').value;
if (!key) {
alert('Please enter a provider key');
showAlert('Please enter a provider key', 'Missing Key', '⚠️', 'warn');
return;
}
if (providersData[key]) {
alert('Provider key already exists');
showAlert('Provider key already exists', 'Duplicate Key', '⚠️', 'warn');
return;
}
......@@ -1836,10 +1836,10 @@ async function saveProviders() {
if (response.ok) {
window.location.href = '/dashboard/providers?success=1';
} else {
alert('Error saving configuration');
showAlert('Error saving configuration', 'Error', '❌', 'danger');
}
} catch (error) {
alert('Error: ' + error.message);
showAlert('Error: ' + error.message, 'Error', '❌', 'danger');
}
}
......
......@@ -169,9 +169,8 @@ async function loadRateLimits() {
}
async function resetRateLimiter(providerId) {
if (!confirm(`Reset rate limiter for ${providerId}?`)) {
return;
}
const ok = await showConfirm(`Reset rate limiter for ${providerId}?`, 'Reset Rate Limiter');
if (!ok) return;
try {
const response = await fetch(`/dashboard/rate-limits/${providerId}/reset`, {
......@@ -180,20 +179,19 @@ async function resetRateLimiter(providerId) {
const data = await response.json();
if (data.success) {
alert(data.message);
showAlert(data.message, 'Done', '✅', 'info');
loadRateLimits();
} else {
alert('Error: ' + data.error);
showAlert('Error: ' + data.error, 'Error', '❌', 'danger');
}
} catch (error) {
alert('Error: ' + error.message);
showAlert('Error: ' + error.message, 'Error', '❌', 'danger');
}
}
async function clearAllRateLimiters() {
if (!confirm('Reset all rate limiters? This will clear all learned rate limits.')) {
return;
}
const ok = await showDangerConfirm('Reset all rate limiters? This will clear all learned rate limits.', 'Reset All');
if (!ok) return;
const content = document.getElementById('rate-limits-content');
const providerIds = [];
......@@ -213,10 +211,10 @@ async function clearAllRateLimiters() {
}
}
alert('All rate limiters reset successfully');
showAlert('All rate limiters reset successfully', 'Done', '✅', 'info');
loadRateLimits();
} catch (error) {
alert('Error: ' + error.message);
showAlert('Error: ' + error.message, 'Error', '❌', 'danger');
}
}
......
......@@ -122,29 +122,24 @@ function refreshStats() {
location.reload();
}
function clearCache() {
if (!confirm('Are you sure you want to clear the entire response cache? This action cannot be undone.')) {
return;
}
async function clearCache() {
const ok = await showDangerConfirm('Are you sure you want to clear the entire response cache? This action cannot be undone.', 'Clear Cache');
if (!ok) return;
fetch('{{ url_for(request, "/dashboard/response-cache/clear") }}', {
try {
const response = await fetch('{{ url_for(request, "/dashboard/response-cache/clear") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
headers: {'Content-Type': 'application/json'}
});
const data = await response.json();
if (data.success) {
alert('Cache cleared successfully!');
location.reload();
showAlert('Cache cleared successfully!', 'Done', '✅', 'info').then(() => location.reload());
} else {
alert('Error clearing cache: ' + (data.error || 'Unknown error'));
showAlert('Error clearing cache: ' + (data.error || 'Unknown error'), 'Error', '❌', 'danger');
}
} catch (error) {
showAlert('Error clearing cache: ' + error, 'Error', '❌', 'danger');
}
})
.catch(error => {
alert('Error clearing cache: ' + error);
});
}
</script>
......
......@@ -268,10 +268,11 @@ function updateGlobalNotify(value) {
rotationsConfig.notifyerrors = value;
}
function addRotation() {
const key = prompt('Enter rotation key (e.g., "coding", "general"):');
if (!key || rotationsConfig.rotations[key]) {
alert('Invalid or duplicate rotation key');
async function addRotation() {
const key = await showPrompt('Enter rotation key (e.g., "coding", "general"):', '', 'rotation-key', 'Add Rotation');
if (!key) return;
if (rotationsConfig.rotations[key]) {
showAlert('Rotation key already exists', 'Duplicate Key', '⚠️', 'warn');
return;
}
......@@ -290,8 +291,8 @@ function addRotation() {
renderRotationsList();
}
function removeRotation(key) {
if (confirm(`Remove rotation "${key}"?`)) {
async function removeRotation(key) {
if (await showDangerConfirm(`Remove rotation "${key}"?`, 'Remove Rotation')) {
delete rotationsConfig.rotations[key];
expandedRotations.delete(key);
renderRotationsList();
......@@ -327,8 +328,8 @@ function addRotationProvider(rotationKey) {
renderRotationProviders(rotationKey);
}
function removeRotationProvider(rotationKey, providerIndex) {
if (confirm('Remove this provider?')) {
async function removeRotationProvider(rotationKey, providerIndex) {
if (await showDangerConfirm('Remove this provider?', 'Remove Provider')) {
rotationsConfig.rotations[rotationKey].providers.splice(providerIndex, 1);
renderRotationProviders(rotationKey);
}
......@@ -353,8 +354,8 @@ function addRotationModel(rotationKey, providerIndex) {
renderRotationModels(rotationKey, providerIndex);
}
function removeRotationModel(rotationKey, providerIndex, modelIndex) {
if (confirm('Remove this model?')) {
async function removeRotationModel(rotationKey, providerIndex, modelIndex) {
if (await showDangerConfirm('Remove this model?', 'Remove Model')) {
rotationsConfig.rotations[rotationKey].providers[providerIndex].models.splice(modelIndex, 1);
renderRotationModels(rotationKey, providerIndex);
}
......@@ -393,10 +394,10 @@ async function saveRotations() {
if (response.ok) {
window.location.href = rotationsData.successUrl;
} else {
alert('Error saving configuration');
showAlert('Error saving configuration', 'Error', '❌', 'danger');
}
} 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/>.
{% block title %}Settings - AISBF Dashboard{% endblock %}
{% 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 %}
<div class="alert alert-success">{{ success }}</div>
......@@ -29,8 +41,26 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div class="alert alert-error">{{ error }}</div>
{% 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">
<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">
<label for="host">Host</label>
......@@ -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>
</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">
<label>
......@@ -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>
</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">
<label>
......@@ -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>
<small style="color: #666; display: block; margin-top: 5px;">Tokens that give access to full system configuration + standard APIs</small>
</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">
<label for="dashboard_username">Dashboard Username</label>
......@@ -117,8 +151,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<label for="dashboard_password">Dashboard Password</label>
<input type="password" id="dashboard_password" name="dashboard_password" placeholder="Leave blank to keep current">
</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">
<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/>.
<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>
</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">
<label for="database_type">Database Type</label>
......@@ -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>
</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">
<label for="cache_type">Cache Type</label>
......@@ -287,7 +327,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</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">
<label>
......@@ -381,7 +421,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</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 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/>.
</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">
<label>
......@@ -447,8 +490,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</label>
<small style="color: #666; display: block; margin-top: 5px;">Enable semantic content classification using the configured model</small>
</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 style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
......@@ -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>
</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">
<label>
......@@ -588,10 +635,12 @@ brew services restart tor # macOS</code></pre>
min="1" max="168">
<small style="color: #666; display: block; margin-top: 5px;">How long verification links remain valid (1-168 hours)</small>
</div>
</div><!-- /tab-signup -->
<h3 style="margin: 30px 0 20px;">OAuth2 Authentication</h3>
<div class="settings-section" id="tab-oauth2">
<div class="section-title"><i class="fab fa-openid"></i> OAuth2 Authentication</div>
<h4 style="margin: 25px 0 15px;">Google OAuth2</h4>
<div class="section-title" style="font-size:.95em; margin-top:0;"><i class="fab fa-google"></i> Google OAuth2</div>
<div class="form-group">
<label>
......@@ -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">
</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">
<label>
......@@ -628,8 +677,10 @@ brew services restart tor # macOS</code></pre>
<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">
</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">
<label>
......@@ -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;">
<button type="button" onclick="testSMTP()" class="btn btn-secondary">Send Test Email</button>
</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">
<label>
......@@ -748,8 +801,10 @@ brew services restart tor # macOS</code></pre>
<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">
</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">
<label>
......@@ -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">
<small style="color: #666; display: block; margin-top: 5px;">Number of successful requests before increasing rate limit (default: 10)</small>
</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">
<label for="new_admin_password">New Admin Password</label>
......@@ -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">
<small style="color: #666; display: block; margin-top: 5px;">Re-enter the new password to confirm</small>
</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>
<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>
......@@ -841,6 +899,13 @@ brew services restart tor # macOS</code></pre>
</form>
<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() {
const host = document.getElementById('smtp_host').value;
const port = document.getElementById('smtp_port').value;
......@@ -849,18 +914,17 @@ async function testSMTP() {
const testEmail = document.getElementById('smtp_test_email').value;
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;
}
if (!testEmail) {
alert('Please enter a test recipient email address');
showAlert('Please enter a test recipient email address', 'Missing Fields', '⚠️', 'warn');
return;
}
if (!confirm(`This will send a test email to ${testEmail}. Continue?`)) {
return;
}
const ok = await showConfirm(`This will send a test email to ${testEmail}. Continue?`, 'Send Test Email');
if (!ok) return;
try {
const response = await fetch('{{ url_for(request, "/dashboard/test-smtp") }}', {
......@@ -882,12 +946,12 @@ async function testSMTP() {
const data = await response.json();
if (data.success) {
alert('Test email sent successfully! Check your inbox.');
showAlert('Test email sent successfully! Check your inbox.', 'Success', '✅', 'info');
} 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) {
alert('Error testing SMTP: ' + error.message);
showAlert('Error testing SMTP: ' + error.message, 'Error', '❌', 'danger');
}
}
</script>
......@@ -973,24 +1037,25 @@ function toggleResponseCacheFields() {
function createPersistentService() {
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';
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 => {
if (!ok) return;
dirInput.value = defaultDir;
dirInput.focus();
updateTorrcExample();
dirInput.style.backgroundColor = '#1a4d2e';
setTimeout(() => { dirInput.style.backgroundColor = ''; }, 500);
});
return;
}
}
dirInput.value = defaultDir;
dirInput.focus();
updateTorrcExample();
// Visual feedback
dirInput.style.backgroundColor = '#1a4d2e';
setTimeout(() => {
dirInput.style.backgroundColor = '';
}, 500);
setTimeout(() => { dirInput.style.backgroundColor = ''; }, 500);
}
function toggleTorrcExample(type) {
......@@ -1103,9 +1168,8 @@ async function refreshCacheStats() {
}
async function clearResponseCache() {
if (!confirm('Are you sure you want to clear the response cache? This will remove all cached responses.')) {
return;
}
const ok = await showDangerConfirm('Are you sure you want to clear the response cache? This will remove all cached responses.', 'Clear Cache');
if (!ok) return;
try {
const response = await fetch('{{ url_for(request, "/dashboard/response-cache/clear") }}', {
......@@ -1114,14 +1178,13 @@ async function clearResponseCache() {
const data = await response.json();
if (data.success) {
alert('Response cache cleared successfully');
showAlert('Response cache cleared successfully', 'Done', '✅', 'info');
refreshCacheStats();
} else {
alert('Failed to clear cache: ' + (data.error || 'Unknown error'));
showAlert('Failed to clear cache: ' + (data.error || 'Unknown error'), 'Error', '❌', 'danger');
}
} catch (error) {
console.error('Error clearing cache:', error);
alert('Error clearing cache: ' + error.message);
showAlert('Error clearing cache: ' + error.message, 'Error', '❌', 'danger');
}
}
</script>
......
......@@ -117,36 +117,13 @@ document.querySelector('form')?.addEventListener('submit', function(e) {
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirm_password').value;
if (password !== confirmPassword) {
e.preventDefault();
alert('Passwords do not match!');
return false;
}
// Check password strength
if (password.length < 8) {
e.preventDefault();
alert('Password must be at least 8 characters long!');
return false;
}
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;
}
const check = (msg) => { e.preventDefault(); showAlert(msg, 'Validation Error', '⚠️', 'warn'); return false; };
if (!/\d/.test(password)) {
e.preventDefault();
alert('Password must contain at least one number!');
return false;
}
if (password !== confirmPassword) return check('Passwords do not match!');
if (password.length < 8) return check('Password must be at least 8 characters long!');
if (!/[a-z]/.test(password)) return check('Password must contain at least one lowercase letter!');
if (!/[A-Z]/.test(password)) return check('Password must contain at least one uppercase letter!');
if (!/\d/.test(password)) return check('Password must contain at least one number!');
return true;
});
......@@ -189,7 +166,7 @@ function openOAuthPopup(url) {
window.removeEventListener('message', messageListener);
// Show error
alert(event.data.error || 'Authentication failed');
showAlert(event.data.error || 'Authentication failed', 'Authentication Error', '❌', 'danger');
}
};
......
......@@ -315,14 +315,14 @@ function renderAutoselectModels(autoselectKey) {
});
}
function addAutoselect() {
const key = prompt('Enter autoselect key (e.g., "autoselect", "smart-select"):');
async function addAutoselect() {
const key = await showPrompt('Enter autoselect key (e.g., "autoselect", "smart-select"):', '', 'e.g. autoselect', 'Add Autoselect');
if (!key) {
return;
}
if (autoselectConfig[key]) {
alert('Autoselect key already exists');
showAlert('Autoselect key already exists', 'Duplicate Key', '⚠️', 'warn');
return;
}
......@@ -338,8 +338,8 @@ function addAutoselect() {
renderAutoselectList();
}
function removeAutoselect(key) {
if (confirm(`Remove autoselect "${key}"?`)) {
async function removeAutoselect(key) {
if (await showDangerConfirm(`Remove autoselect "${key}"?`, 'Remove Autoselect')) {
delete autoselectConfig[key];
expandedAutoselects.delete(key);
renderAutoselectList();
......@@ -375,8 +375,8 @@ function addAutoselectModel(autoselectKey) {
renderAutoselectModels(autoselectKey);
}
function removeAutoselectModel(autoselectKey, index) {
if (confirm('Remove this model?')) {
async function removeAutoselectModel(autoselectKey, index) {
if (await showDangerConfirm('Remove this model?', 'Remove Model')) {
autoselectConfig[autoselectKey].available_models.splice(index, 1);
renderAutoselectModels(autoselectKey);
}
......@@ -399,10 +399,10 @@ async function saveAutoselect() {
if (response.ok) {
window.location.href = autoselectData.successUrl;
} else {
alert('Error saving configuration');
showAlert('Error saving configuration', 'Error', '❌', 'danger');
}
} catch (error) {
alert('Error: ' + error.message);
showAlert('Error: ' + error.message, 'Error', '❌', 'danger');
}
}
......
{% extends "base.html" %}
{% block title %}User Dashboard - AISBF{% endblock %}
{% block title %}Dashboard - AISBF{% endblock %}
{% macro format_tokens(value) %}
{% if value is none or value == 0 %}0{% else %}
......@@ -18,133 +18,407 @@
{% endmacro %}
{% block content %}
<div class="container">
<h1>User Dashboard</h1>
<p>Welcome, {{ session.username }}!</p>
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
<style>
/* ── Hero ──────────────────────────────────────────────────── */
.hero {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 1.5rem 2rem;
background: linear-gradient(135deg, #0f3460 0%, #16213e 60%, #1a1a2e 100%);
border-radius: 12px;
margin-bottom: 1.75rem;
border: 1px solid #0f3460;
position: relative;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
top: -60px; right: -60px;
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;
}
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
/* ── Stat cards ─────────────────────────────────────────────── */
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 1rem;
margin-bottom: 1.75rem;
}
.stat-card {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 10px;
padding: 1.25rem 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
transition: border-color .2s;
}
.stat-card:hover { border-color: #667eea; }
.stat-icon {
width: 44px; height: 44px;
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 1.2rem;
flex-shrink: 0;
}
.stat-icon.purple { background: rgba(103,126,234,.15); color: #667eea; }
.stat-icon.green { background: rgba(74,222,128,.12); color: #4ade80; }
.stat-icon.blue { background: rgba(96,165,250,.12); color: #60a5fa; }
.stat-icon.orange { background: rgba(251,146,60,.12); color: #fb923c; }
.stat-label { font-size: .78rem; color: #888; margin-bottom: .25rem; }
.stat-value { font-size: 1.6rem; font-weight: 700; color: #e0e0e0; line-height: 1; }
/* ── Two-column layout ──────────────────────────────────────── */
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 1.75rem;
}
@media (max-width: 900px) { .dashboard-grid { grid-template-columns: 1fr; } }
<!-- 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>
/* ── Section card ───────────────────────────────────────────── */
.section-card {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 10px;
overflow: hidden;
}
.section-card.full-width { grid-column: 1 / -1; }
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: .9rem 1.25rem;
border-bottom: 1px solid #0f3460;
background: rgba(15,52,96,.3);
}
.section-header h3 {
font-size: 1rem;
font-weight: 600;
color: #e0e0e0;
display: flex; align-items: center; gap: .5rem;
}
.section-header h3 i { color: #667eea; font-size: .9rem; }
.section-body { padding: 1.25rem; }
<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>
/* ── Quick actions ──────────────────────────────────────────── */
.actions-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: .75rem;
}
.action-card {
display: flex;
align-items: center;
gap: .75rem;
padding: .85rem 1rem;
background: #1a1a2e;
border: 1px solid #0f3460;
border-radius: 8px;
text-decoration: none;
color: #e0e0e0;
transition: border-color .2s, background .2s, transform .15s;
}
.action-card:hover {
border-color: #667eea;
background: rgba(103,126,234,.08);
transform: translateY(-1px);
}
.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; }
<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>
/* ── Subscription card ──────────────────────────────────────── */
.sub-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.sub-plan h4 { font-size: 1.1rem; font-weight: 700; color: #e0e0e0; margin-bottom: .3rem; }
.badge {
display: inline-block;
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 {
padding: .7rem .9rem;
font-size: .88rem;
color: #c0c0c0;
border-bottom: 1px solid rgba(15,52,96,.5);
}
.activity-table tr:last-child td { border-bottom: none; }
.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 {
display: flex;
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;
}
<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>
.admin-banner {
background: linear-gradient(135deg, rgba(103,126,234,.2) 0%, rgba(118,75,162,.2) 100%);
border: 1px solid rgba(103,126,234,.4);
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; }
<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>
.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;
margin-top: .75rem;
}
.tip-box a { color: #667eea; }
</style>
<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>
{% if success %}
<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 %}
<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>
<!-- Hero -->
<div class="hero">
<div class="hero-avatar">
<img src="https://www.gravatar.com/avatar/{{ session.email|md5 if session.email else '' }}?s=128&d=identicon" alt="avatar">
</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="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>
<div class="hero-badge">Admin</div>
{% endif %}
</div>
<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>
<!-- Stats row -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-icon purple"><i class="fas fa-coins"></i></div>
<div>
<div class="stat-label">Total Tokens</div>
<div class="stat-value">{{ format_tokens(usage_stats.total_tokens|default(0)) }}</div>
</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 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 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 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>
<!-- Main grid -->
<div class="dashboard-grid">
<!-- 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 class="section-card">
<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>
<!-- Subscription -->
{% if current_tier %}
<div class="section-card">
<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 %}
<!-- Recent Activity -->
<div class="card">
<h2>Recent Activity</h2>
<table class="table">
<div class="section-card full-width">
<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>
......@@ -157,314 +431,137 @@
{% if recent_activity %}
{% for activity in recent_activity %}
<tr>
<td>{{ activity.timestamp }}</td>
<td class="mono">{{ activity.timestamp }}</td>
<td>{{ activity.provider_id }}</td>
<td>{{ activity.model }}</td>
<td class="mono">{{ activity.model }}</td>
<td>{{ activity.token_count }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" style="text-align: center;">No recent activity</td>
</tr>
<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>
<!-- 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>
<!-- API Endpoints (collapsible) -->
<div class="section-card full-width">
<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>
{% endif %}
<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>
{% 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 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>
{% endif %}
</div>
</div>
{% endif %}
</div>
<style>
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.stat-item {
background: #1a1a2e;
padding: 1.5rem;
border-radius: 8px;
text-align: center;
}
.stat-item h3 {
font-size: 0.9rem;
color: #888;
margin-bottom: 0.5rem;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: #16213e;
}
.button-group {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-top: 1rem;
}
.table {
width: 100%;
margin-top: 1rem;
border-collapse: collapse;
}
.table th,
.table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #333;
}
.table th {
background: #1a1a2e;
font-weight: bold;
}
.table tbody tr:hover {
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;
}
.endpoint .method {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 3px;
font-weight: bold;
font-size: 0.8rem;
margin-right: 0.5rem;
}
.endpoint .method.GET {
background: #28a745;
color: white;
}
.endpoint .method.POST {
background: #007bff;
color: white;
}
.endpoint .url {
color: #e0e0e0;
font-family: monospace;
}
.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;
}
.endpoint code {
background: #1a1a2e;
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-size: 0.9rem;
}
.note {
margin-top: 1rem;
padding: 1rem;
background: #1a1a2e;
border-radius: 4px;
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;
color: white;
}
.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;
}
.admin-notice code {
background: rgba(0,0,0,0.3);
padding: 0.2rem 0.4rem;
border-radius: 3px;
}
/* Subscription Section */
.subscription-info {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.plan-info h3 {
margin: 0 0 0.5rem 0;
color: #e0e0e0;
}
.free-tier-badge {
background: #28a745;
color: white;
padding: 4px 12px;
border-radius: 15px;
font-size: 12px;
font-weight: bold;
}
.plan-price {
margin-top: 0.5rem;
}
.price {
font-size: 1.5rem;
font-weight: bold;
color: #4ade80;
}
.price-secondary {
color: #a0a0a0;
font-size: 0.9rem;
margin-left: 0.5rem;
}
.subscription-details {
text-align: center;
}
.subscription-status {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.status-badge {
padding: 4px 12px;
border-radius: 15px;
font-size: 12px;
font-weight: bold;
}
.status-active {
background: #28a745;
color: white;
}
.status-canceled {
background: #ffc107;
color: black;
}
.status-expired,
.status-suspended {
background: #dc3545;
color: white;
}
{% 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 %}
.renewal-date {
color: #a0a0a0;
font-size: 0.9rem;
}
<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>
.payment-method-section {
text-align: center;
margin-top: 1rem;
}
</div>
@media (max-width: 768px) {
.subscription-info {
flex-direction: column;
text-align: center;
}
<script>
function toggleEndpoints() {
const toggle = document.getElementById('epToggle');
const body = document.getElementById('epBody');
toggle.classList.toggle('open');
body.classList.toggle('visible');
}
</style>
</script>
{% endblock %}
......@@ -256,7 +256,7 @@ function showToast(message, type) {
let providersData = {};
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
rawProviders.forEach(provider => {
......@@ -1705,8 +1705,8 @@ async function pollKiloAuth(providerKey, deviceCode) {
// This function is no longer needed as polling is handled in authenticateKilo
}
function removeProvider(key) {
if (confirm(`Remove provider "${key}"?`)) {
async function removeProvider(key) {
if (await showDangerConfirm(`Remove provider "${key}"?`, 'Remove Provider')) {
delete providersData[key];
expandedProviders.delete(key);
renderProvidersList();
......@@ -1786,8 +1786,8 @@ function addModel(key) {
renderModels(key);
}
function removeModel(providerKey, index) {
if (confirm('Remove this model?')) {
async function removeModel(providerKey, index) {
if (await showDangerConfirm('Remove this model?', 'Remove Model')) {
providersData[providerKey].models.splice(index, 1);
renderModels(providerKey);
}
......@@ -1798,12 +1798,12 @@ async function confirmAddProvider() {
const type = document.getElementById('new-provider-type').value;
if (!key) {
alert('Please enter a provider key');
showAlert('Please enter a provider key', 'Missing Key', '⚠️', 'warn');
return;
}
if (providersData[key]) {
alert('Provider key already exists');
showAlert('Provider key already exists', 'Duplicate Key', '⚠️', 'warn');
return;
}
......@@ -1931,10 +1931,10 @@ async function saveProviders() {
if (response.ok) {
window.location.href = '/dashboard/providers?success=1';
} else {
alert('Error saving configuration');
showAlert('Error saving configuration', 'Error', '❌', 'danger');
}
} catch (error) {
alert('Error: ' + error.message);
showAlert('Error: ' + error.message, 'Error', '❌', 'danger');
}
}
......
......@@ -259,10 +259,11 @@ function renderRotationModels(rotationKey, providerIndex) {
});
}
function addRotation() {
const key = prompt('Enter rotation key (e.g., "coding", "general"):');
if (!key || rotationsConfig.rotations[key]) {
alert('Invalid or duplicate rotation key');
async function addRotation() {
const key = await showPrompt('Enter rotation key (e.g., "coding", "general"):', '', 'rotation-key', 'Add Rotation');
if (!key) return;
if (rotationsConfig.rotations[key]) {
showAlert('Rotation key already exists', 'Duplicate Key', '⚠️', 'warn');
return;
}
......@@ -281,8 +282,8 @@ function addRotation() {
renderRotationsList();
}
function removeRotation(key) {
if (confirm(`Remove rotation "${key}"?`)) {
async function removeRotation(key) {
if (await showDangerConfirm(`Remove rotation "${key}"?`, 'Remove Rotation')) {
delete rotationsConfig.rotations[key];
expandedRotations.delete(key);
renderRotationsList();
......@@ -318,8 +319,8 @@ function addRotationProvider(rotationKey) {
renderRotationProviders(rotationKey);
}
function removeRotationProvider(rotationKey, providerIndex) {
if (confirm('Remove this provider?')) {
async function removeRotationProvider(rotationKey, providerIndex) {
if (await showDangerConfirm('Remove this provider?', 'Remove Provider')) {
rotationsConfig.rotations[rotationKey].providers.splice(providerIndex, 1);
renderRotationProviders(rotationKey);
}
......@@ -344,8 +345,8 @@ function addRotationModel(rotationKey, providerIndex) {
renderRotationModels(rotationKey, providerIndex);
}
function removeRotationModel(rotationKey, providerIndex, modelIndex) {
if (confirm('Remove this model?')) {
async function removeRotationModel(rotationKey, providerIndex, modelIndex) {
if (await showDangerConfirm('Remove this model?', 'Remove Model')) {
rotationsConfig.rotations[rotationKey].providers[providerIndex].models.splice(modelIndex, 1);
renderRotationModels(rotationKey, providerIndex);
}
......@@ -384,10 +385,10 @@ async function saveRotations() {
if (response.ok) {
window.location.href = rotationsData.successUrl;
} else {
alert('Error saving configuration');
showAlert('Error saving configuration', 'Error', '❌', 'danger');
}
} catch (error) {
alert('Error: ' + error.message);
showAlert('Error: ' + error.message, 'Error', '❌', 'danger');
}
}
......
{% extends "base.html" %}
{% block title %}My API Tokens - AISBF{% endblock %}
{% block title %}API Tokens - AISBF{% endblock %}
{% block content %}
<div class="container">
<h1>My API Tokens</h1>
<p>Manage your personal API tokens for accessing the AISBF API</p>
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
{% if error %}
<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>
<style>
/* ── Page header ──────────────────────────────────────────── */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.page-header h2 {
font-size: 1.35rem;
font-weight: 700;
color: #e0e0e0;
display: flex;
align-items: center;
gap: .6rem;
}
.page-header h2 i { color: #667eea; }
.page-header p { color: #888; font-size: .9rem; margin-top: .2rem; }
/* ── Section card ─────────────────────────────────────────── */
.section-card {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 10px;
overflow: hidden;
margin-bottom: 1.5rem;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: .85rem 1.25rem;
border-bottom: 1px solid #0f3460;
background: rgba(15,52,96,.3);
}
.section-header h3 {
font-size: .95rem;
font-weight: 600;
color: #e0e0e0;
display: flex;
align-items: center;
gap: .5rem;
}
.section-header h3 i { color: #667eea; font-size: .85rem; }
.section-body { padding: 1.25rem; }
/* ── Token card ───────────────────────────────────────────── */
.token-list { display: flex; flex-direction: column; gap: .85rem; }
.token-card {
background: #1a1a2e;
border: 1px solid #0f3460;
border-radius: 8px;
padding: 1rem 1.25rem;
transition: border-color .2s;
}
.token-card:hover { border-color: #334477; }
.token-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: .75rem;
}
.token-name {
font-size: 1rem;
font-weight: 600;
color: #e0e0e0;
margin-bottom: .2rem;
}
.token-meta {
font-size: .8rem;
color: #666;
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.token-meta span { display: flex; align-items: center; gap: .3rem; }
.token-meta i { color: #445; font-size: .75rem; }
<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
.token-actions { display: flex; align-items: center; gap: .6rem; flex-shrink: 0; }
# List your providers
curl -H "Authorization: Bearer YOUR_TOKEN" {{ request.base_url }}api/u/{{ session.username }}/providers
.status-pill {
display: inline-flex;
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; }
# 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>
.btn-sm {
padding: .35rem .8rem !important;
font-size: .8rem !important;
margin-left: 0 !important;
}
<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>
/* token value row */
.token-value-row {
display: flex;
align-items: center;
gap: .6rem;
background: #0f1c33;
border: 1px solid #0f3460;
border-radius: 6px;
padding: .55rem .85rem;
font-family: monospace;
font-size: .85rem;
color: #a0a0a0;
}
.token-value-text { flex: 1; word-break: break-all; }
.copy-btn {
background: rgba(103,126,234,.15);
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 {
text-align: center;
padding: 3rem 1rem;
color: #555;
}
.empty-state i {
font-size: 3rem;
margin-bottom: 1rem;
display: block;
color: #2a3a5e;
}
.empty-state p { font-size: .9rem; margin-bottom: 1.25rem; }
/* ── Create token form (inline) ───────────────────────────── */
.create-form-area {
display: none;
background: #1a1a2e;
border: 1px solid #667eea;
border-radius: 8px;
padding: 1.25rem;
margin-top: 1rem;
}
.create-form-area.visible { display: block; }
.create-form-area .form-group { margin-bottom: 1rem; }
.create-form-area label { font-size: .85rem; color: #a0a0a0; margin-bottom: .4rem; display: block; }
.create-form-area input[type="text"] {
width: 100%;
padding: .6rem .85rem;
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; }
/* new token reveal */
.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; }
/* ── API docs section ─────────────────────────────────────── */
.docs-toggle {
cursor: pointer;
user-select: none;
}
.docs-body { display: none; }
.docs-body.visible { display: block; }
.caret { display: inline-block; transition: transform .2s; }
.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 {
padding: .6rem .75rem;
font-size: .85rem;
color: #c0c0c0;
border-bottom: 1px solid rgba(15,52,96,.4);
}
.ep-table tr:last-child td { border-bottom: none; }
.method-badge {
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>
{% if success %}
<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 %}
<!-- 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>
<!-- 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>
<!-- 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="modal-body">
<div id="token-creation-form">
<form id="create-token-form">
<div class="section-body">
<!-- Create form (inline, hidden by default) -->
<div class="create-form-area" id="createFormArea">
<div class="form-group">
<label for="token-description">Description (optional):</label>
<input type="text" id="token-description" placeholder="e.g., My application token">
<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 type="submit" class="btn btn-primary">Create Token</button>
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary btn-sm" onclick="submitCreateToken()">Create</button>
<button class="btn btn-secondary btn-sm" onclick="toggleCreateForm()">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>
<!-- 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>
......@@ -130,336 +341,156 @@ curl -X POST -H "Authorization: Bearer YOUR_TOKEN" \
<script>
let tokens = {{ user_tokens | tojson }};
/* ── Render tokens ──────────────────────────────────────── */
function renderTokens() {
const container = document.getElementById('tokens-list');
container.innerHTML = '';
const list = document.getElementById('tokenList');
if (tokens.length === 0) {
container.innerHTML = '<p class="empty-state">No API tokens created yet. Click "Create New Token" to get started.</p>';
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;
}
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>
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="token-status ${token.is_active ? 'active' : 'inactive'}">
${token.is_active ? 'Active' : 'Inactive'}
<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(${token.id})">Delete</button>
<button class="btn btn-danger btn-sm" onclick="deleteToken(${t.id})">
<i class="fas fa-trash"></i>
</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 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>
`;
container.appendChild(div);
});
</div>
`).join('');
}
function createToken() {
document.getElementById('token-creation-form').style.display = 'block';
document.getElementById('token-result').style.display = 'none';
document.getElementById('create-token-form').reset();
document.getElementById('token-modal').style.display = 'block';
function escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function closeModal() {
document.getElementById('token-modal').style.display = 'none';
function formatDate(iso) {
if (!iso) return '';
return new Date(iso).toLocaleString(undefined, {dateStyle:'medium', timeStyle:'short'});
}
function deleteToken(tokenId) {
if (!confirm('Are you sure you want to delete this API token? This action cannot be undone and will immediately revoke access.')) return;
fetch('{{ url_for(request, "/dashboard/user/tokens") }}/' + tokenId, {
method: 'DELETE',
headers: {
'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');
});
/* ── Create form ──────────────────────────────────────────── */
function toggleCreateForm() {
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');
}
}).catch(error => {
alert('Error: ' + error.message);
});
}
function copyToken() {
const tokenElement = document.getElementById('created-token');
const token = tokenElement.textContent;
navigator.clipboard.writeText(token).then(() => {
alert('Token copied to clipboard!');
}).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!');
});
}
// Handle form submission
document.getElementById('create-token-form').addEventListener('submit', function(e) {
e.preventDefault();
const description = document.getElementById('token-description').value.trim();
function submitCreateToken() {
const desc = document.getElementById('tokenDescription').value.trim();
const formData = new FormData();
if (description) {
formData.append('description', description);
}
if (desc) formData.append('description', desc);
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';
})
.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');
// Refresh the token list
tokens.push({
id: data.token_id,
token: data.token,
description: description || null,
description: desc || 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;
padding: 1rem;
margin: 1rem 0;
border-radius: 8px;
border: 1px solid #0f3460;
}
.token-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.token-info h4 {
margin: 0 0 0.5rem 0;
color: #e0e0e0;
}
.token-meta {
color: #a0a0a0;
font-size: 0.9rem;
margin: 0;
}
.token-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.token-status {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
}
.token-status.active {
background: #28a745;
color: white;
}
.token-status.inactive {
background: #dc3545;
color: white;
}
.token-details {
background: #16213e;
padding: 1rem;
border-radius: 4px;
font-family: monospace;
font-size: 0.9rem;
}
.token-details p {
margin: 0.5rem 0;
word-break: break-all;
}
.empty-state {
text-align: center;
color: #a0a0a0;
padding: 2rem;
}
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: #1a1a2e;
border-radius: 8px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #0f3460;
}
.modal-header h3 {
margin: 0;
}
.close-btn {
background: none;
border: none;
color: #e0e0e0;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.modal-body {
padding: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #e0e0e0;
})
.catch(err => showAlert('Error: ' + err, 'Error', '❌', 'danger'));
}
.form-group input[type="text"] {
width: 100%;
padding: 0.5rem;
border: 1px solid #0f3460;
border-radius: 4px;
background: #16213e;
color: #e0e0e0;
font-size: 14px;
function dismissReveal() {
document.getElementById('newTokenReveal').classList.remove('visible');
}
.form-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1rem;
/* ── Copy helpers ─────────────────────────────────────────── */
function copyNewToken() {
const token = document.getElementById('newTokenValue').textContent;
copyToClipboard(token, document.querySelector('#newTokenReveal .copy-btn'));
}
.warning-text {
color: #ffc107;
font-weight: 500;
function copyPartialToken(btn, fullToken) {
copyToClipboard(fullToken, btn);
}
#created-token {
background: #0f3460;
padding: 0.5rem;
border-radius: 4px;
font-family: monospace;
word-break: break-all;
}
/* API Endpoints Table */
.endpoints-table {
width: 100%;
margin: 1rem 0;
border-collapse: collapse;
}
.endpoints-table th,
.endpoints-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #333;
function copyToClipboard(text, btn) {
navigator.clipboard.writeText(text).then(() => {
if (btn) {
const orig = btn.textContent;
btn.textContent = 'Copied!';
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 th {
background: #1a1a2e;
font-weight: bold;
}
/* ── Delete ───────────────────────────────────────────────── */
async function deleteToken(id) {
const ok = await showDangerConfirm('Delete this API token? This will immediately revoke access and cannot be undone.', 'Delete Token');
if (!ok) return;
.endpoints-table .method {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 3px;
font-weight: bold;
font-size: 0.75rem;
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.GET {
background: #28a745;
color: white;
/* ── Docs toggle ──────────────────────────────────────────── */
function toggleDocs() {
document.getElementById('docsToggle').classList.toggle('open');
document.getElementById('docsBody').classList.toggle('visible');
}
.endpoints-table .method.POST {
background: #007bff;
color: white;
}
</style>
renderTokens();
</script>
{% endblock %}
......@@ -688,7 +688,7 @@ function clearSelection() {
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));
if (selectedIds.length === 0) {
......@@ -696,8 +696,9 @@ function performBulkAction(action, description, destructive = false, extraData =
return;
}
if (destructive && !confirm(`Are you sure you want to ${description}? This action cannot be undone.`)) {
return;
if (destructive) {
const ok = await showDangerConfirm(`Are you sure you want to ${description}? This action cannot be undone.`, 'Confirm Action');
if (!ok) return;
}
// Show loading state
......@@ -754,9 +755,10 @@ function closeEditModal() {
document.getElementById('edit-modal').style.display = 'none';
}
function toggleUserStatus(userId, currentStatus) {
async function toggleUserStatus(userId, currentStatus) {
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', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
......@@ -766,17 +768,18 @@ function toggleUserStatus(userId, currentStatus) {
if (data.success) {
location.reload();
} else {
alert(data.error || 'Failed to toggle user status');
showAlert(data.error || 'Failed to toggle user status', 'Error', '❌', 'danger');
}
})
.catch(error => {
alert('Error: ' + error);
showAlert('Error: ' + error, 'Error', '❌', 'danger');
});
}
}
function deleteUser(userId, username) {
if (confirm('Are you sure you want to delete user "' + username + '"? This action cannot be undone.')) {
async function deleteUser(userId, username) {
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', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
......@@ -786,11 +789,11 @@ function deleteUser(userId, username) {
if (data.success) {
location.reload();
} else {
alert(data.error || 'Failed to delete user');
showAlert(data.error || 'Failed to delete user', 'Error', '❌', 'danger');
}
})
.catch(error => {
alert('Error: ' + error);
showAlert('Error: ' + error, 'Error', '❌', 'danger');
});
}
}
......
......@@ -10,7 +10,7 @@
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 15px;">
<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;">
Currency: <span style="color: #e0e0e0;">{{ wallet.currency_code }}</span>
&nbsp;·&nbsp; Wallet ID: <span style="color: #e0e0e0; font-family: monospace;">{{ wallet.id }}</span>
......@@ -56,7 +56,7 @@
{% for amt in [10, 15, 20, 50, 100] %}
<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;">
${{ amt }}
{{ currency_symbol }}{{ amt }}
</button>
{% endfor %}
</div>
......@@ -65,7 +65,7 @@
<label style="display: block; color: #a0a0a0; font-size: 13px; margin-bottom: 6px;">Custom amount</label>
<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;"
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 style="display: flex; flex-direction: column; gap: 10px; {% if crypto_gateways %}margin-bottom: 16px;{% endif %}">
{% for name in fiat_gateways %}
......@@ -89,33 +89,23 @@
<hr style="border-color: #0f3460; margin: 4px 0 14px 0;">
{% endif %}
<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 style="display: flex; flex-wrap: wrap; gap: 8px;">
{% for name in crypto_gateways %}
{% set cfg = enabled_gateways[name] %}
{% 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;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
<span style="color: #e0e0e0; font-weight: 600;">
{% if name == 'bitcoin' %}<i class="fab fa-bitcoin me-1" style="color:#f7931a;"></i>
{% elif name == 'ethereum' %}<i class="fab fa-ethereum me-1" style="color:#627eea;"></i>
{% else %}<i class="fas fa-coins me-1" style="color:#26a17b;"></i>
<button onclick="openCryptoModal('{{ name }}','{{ address }}')"
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;"
onmouseover="this.style.borderColor='#4a9eff'" onmouseout="this.style.borderColor='#0f3460'">
{% if name == 'bitcoin' %}<i class="fab fa-bitcoin" style="color:#f7931a;"></i>
{% elif name == 'ethereum' %}<i class="fab fa-ethereum" style="color:#627eea;"></i>
{% else %}<i class="fas fa-coins" style="color:#26a17b;"></i>
{% endif %}
{{ name | upper }}
</span>
<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 %}
</div>
{% endif %}
{% endif %}
......@@ -144,7 +134,7 @@
<div style="margin-bottom: 14px;">
<label style="display: block; color: #a0a0a0; font-size: 13px; margin-bottom: 6px;">Top-up amount</label>
<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"
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">
......@@ -153,7 +143,7 @@
<div style="margin-bottom: 14px;">
<label style="display: block; color: #a0a0a0; font-size: 13px; margin-bottom: 6px;">Trigger when balance falls below</label>
<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"
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">
......@@ -212,6 +202,27 @@
</table>
</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 %}
{% block extra_js %}
......@@ -258,7 +269,7 @@ document.addEventListener('DOMContentLoaded', function () {
btn.addEventListener('click', function () {
const amount = getAmount();
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;
}
const method = this.dataset.method;
......@@ -279,13 +290,13 @@ document.addEventListener('DOMContentLoaded', function () {
} else {
this.disabled = false;
this.innerHTML = orig;
alert(data.error || 'Failed to initiate checkout.');
showAlert(data.error || 'Failed to initiate checkout.', 'Error', '❌', 'danger');
}
})
.catch(() => {
this.disabled = false;
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 () {
this.textContent = '✓ Saved';
setTimeout(() => { this.innerHTML = '<i class="fas fa-save me-2"></i>Save Settings'; }, 2000);
} 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 ─────────────────────────────────────
......@@ -407,7 +418,44 @@ function copyAddress(addr, btn) {
btn.innerHTML = '<i class="fas fa-check me-1"></i>Copied!';
btn.style.background = '#28a745';
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>
{% 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