0.99.56

parent 3795e9bf
......@@ -1328,6 +1328,7 @@ class DatabaseManager:
cursor.execute(f'DELETE FROM user_autoselects WHERE user_id = {placeholder}', (user_id,))
cursor.execute(f'DELETE FROM user_api_tokens WHERE user_id = {placeholder}', (user_id,))
cursor.execute(f'DELETE FROM user_token_usage WHERE user_id = {placeholder}', (user_id,))
cursor.execute(f'DELETE FROM user_notifications WHERE user_id = {placeholder}', (user_id,))
# Delete the user
cursor.execute(f'DELETE FROM users WHERE id = {placeholder}', (user_id,))
conn.commit()
......@@ -1377,6 +1378,95 @@ class DatabaseManager:
cursor.execute(query, params)
conn.commit()
def create_notification(self, user_id: int, title: str, message: str, notification_type: str = 'message') -> int:
"""Create a notification for a user. Returns the new notification id."""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
INSERT INTO user_notifications (user_id, title, message, notification_type)
VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder})
''', (user_id, title, message, notification_type))
conn.commit()
return cursor.lastrowid
def get_user_notifications(self, user_id: int, limit: int = 50, unread_only: bool = False) -> List[Dict]:
"""Return notifications for a user, newest first."""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
where = f'user_id = {placeholder}'
params: list = [user_id]
if unread_only:
where += f' AND is_read = 0'
cursor.execute(f'''
SELECT id, title, message, notification_type, is_read, created_at
FROM user_notifications
WHERE {where}
ORDER BY created_at DESC
LIMIT {placeholder}
''', params + [limit])
rows = cursor.fetchall()
return [
{
'id': r[0],
'title': r[1],
'message': r[2],
'notification_type': r[3],
'is_read': bool(r[4]),
'created_at': str(r[5]),
}
for r in rows
]
def get_unread_notification_count(self, user_id: int) -> int:
"""Return count of unread notifications for a user."""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
SELECT COUNT(*) FROM user_notifications
WHERE user_id = {placeholder} AND is_read = 0
''', (user_id,))
row = cursor.fetchone()
return row[0] if row else 0
def mark_notification_read(self, notification_id: int, user_id: int) -> bool:
"""Mark a single notification as read. Returns True if updated."""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
UPDATE user_notifications SET is_read = 1
WHERE id = {placeholder} AND user_id = {placeholder}
''', (notification_id, user_id))
conn.commit()
return cursor.rowcount > 0
def mark_all_notifications_read(self, user_id: int) -> int:
"""Mark all notifications as read for a user. Returns count updated."""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
UPDATE user_notifications SET is_read = 1
WHERE user_id = {placeholder} AND is_read = 0
''', (user_id,))
conn.commit()
return cursor.rowcount
def delete_notification(self, notification_id: int, user_id: int) -> bool:
"""Delete a notification. Returns True if deleted."""
with self._get_connection() as conn:
cursor = conn.cursor()
placeholder = '?' if self.db_type == 'sqlite' else '%s'
cursor.execute(f'''
DELETE FROM user_notifications
WHERE id = {placeholder} AND user_id = {placeholder}
''', (notification_id, user_id))
conn.commit()
return cursor.rowcount > 0
def verify_user_password(self, user_id: int, password: str) -> bool:
"""
Verify a user's plain-text password against the stored hash.
......@@ -4086,6 +4176,46 @@ def DatabaseManager__run_config_migrations(self, cursor, auto_increment, timesta
except Exception as e:
logger.warning(f"Migration check for user_api_tokens.scope: {e}")
# Migration: Create user_notifications table if missing
try:
if self.db_type == 'sqlite':
cursor.execute("PRAGMA table_info(user_notifications)")
if not cursor.fetchall():
cursor.execute(f'''
CREATE TABLE user_notifications (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
title VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
notification_type VARCHAR(50) DEFAULT 'message',
is_read {boolean_type} DEFAULT 0,
created_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id)
)
''')
logger.info("✅ Migration: Created user_notifications table")
else:
cursor.execute("""
SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_notifications'
""")
if not cursor.fetchone():
cursor.execute(f'''
CREATE TABLE user_notifications (
id INTEGER PRIMARY KEY {auto_increment},
user_id INTEGER NOT NULL,
title VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
notification_type VARCHAR(50) DEFAULT 'message',
is_read {boolean_type} DEFAULT 0,
created_at TIMESTAMP DEFAULT {timestamp_default},
FOREIGN KEY (user_id) REFERENCES users(id)
)
''')
logger.info("✅ Migration: Created user_notifications table")
except Exception as e:
logger.warning(f"Migration check for user_notifications table: {e}")
logger.info("✅ All database migrations completed")
# Patch the methods
......
......@@ -439,3 +439,35 @@ AISBF Team
logger.error(f"Exception sending test email to {to_email}: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
return False
def send_simple_email(to_email: str, subject: str, body_html: str, smtp_config) -> bool:
"""Send a simple HTML email. Returns True on success."""
if not smtp_config or not getattr(smtp_config, 'host', None) or not getattr(smtp_config, 'from_email', None):
logger.warning("send_simple_email: SMTP not configured, skipping")
return False
try:
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = f"{smtp_config.from_name} <{smtp_config.from_email}>" if getattr(smtp_config, 'from_name', None) else smtp_config.from_email
msg['To'] = to_email
msg.attach(MIMEText(body_html, 'html'))
if getattr(smtp_config, 'use_ssl', False):
with smtplib.SMTP_SSL(smtp_config.host, smtp_config.port) as server:
if getattr(smtp_config, 'username', None) and getattr(smtp_config, 'password', None):
server.login(smtp_config.username, smtp_config.password)
server.send_message(msg)
else:
with smtplib.SMTP(smtp_config.host, smtp_config.port) as server:
server.ehlo()
if getattr(smtp_config, 'use_tls', True):
server.starttls()
server.ehlo()
if getattr(smtp_config, 'username', None) and getattr(smtp_config, 'password', None):
server.login(smtp_config.username, smtp_config.password)
server.send_message(msg)
return True
except Exception as e:
logger.error(f"send_simple_email error: {e}")
return False
......@@ -418,8 +418,48 @@ class EmailNotificationService:
conn.commit()
def _notify_admin(self, event_key: str, subject: str, body_html: str):
"""Send an email to the admin if that event type is enabled in config."""
try:
import json
from pathlib import Path
config_path = Path.home() / '.aisbf' / 'aisbf.json'
if not config_path.exists():
config_path = Path(__file__).parent.parent.parent / 'config' / 'aisbf.json'
if not config_path.exists():
return
with open(config_path) as f:
cfg = json.load(f)
dashboard = cfg.get('dashboard', {})
admin_email = dashboard.get('email', '')
if not admin_email:
return
notifications = dashboard.get('notifications', {})
if not notifications.get(event_key, False):
return
smtp = cfg.get('smtp', {})
if not smtp.get('enabled', False) or not smtp.get('host'):
return
from aisbf.email_utils import send_simple_email
class _SmtpCfg:
pass
smtp_cfg = _SmtpCfg()
smtp_cfg.host = smtp.get('host', '')
smtp_cfg.port = smtp.get('port', 587)
smtp_cfg.username = smtp.get('username', '')
smtp_cfg.password = smtp.get('password', '')
smtp_cfg.use_tls = smtp.get('use_tls', True)
smtp_cfg.use_ssl = smtp.get('use_ssl', False)
smtp_cfg.from_email = smtp.get('from_email', '')
smtp_cfg.from_name = smtp.get('from_name', 'AISBF')
send_simple_email(admin_email, subject, body_html, smtp_cfg)
except Exception as e:
logger.warning(f"Admin notification ({event_key}): {e}")
# Convenience methods for common notifications
async def notify_payment_success(self, user_id: int, amount: float, currency: str):
"""Send payment success notification"""
await self.send_notification(
......@@ -431,7 +471,12 @@ class EmailNotificationService:
'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
)
self._notify_admin(
'payment_received',
f"Payment received: {amount} {currency}",
f"<h2>Payment Received</h2><p>User ID {user_id} completed a payment of <b>{amount} {currency}</b>.</p>"
)
async def notify_payment_failed(self, user_id: int, amount: float, currency: str, reason: str):
"""Send payment failed notification"""
await self.send_notification(
......@@ -444,7 +489,7 @@ class EmailNotificationService:
'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
)
async def notify_subscription_upgraded(self, user_id: int, old_tier: str, new_tier: str):
"""Send subscription upgraded notification"""
await self.send_notification(
......@@ -456,7 +501,12 @@ class EmailNotificationService:
'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
)
self._notify_admin(
'tier_upgrade',
f"Subscription upgraded: {old_tier} → {new_tier}",
f"<h2>Subscription Upgraded</h2><p>User ID {user_id} upgraded from <b>{old_tier}</b> to <b>{new_tier}</b>.</p>"
)
async def notify_subscription_downgraded(self, user_id: int, old_tier: str, new_tier: str):
"""Send subscription downgraded notification"""
await self.send_notification(
......@@ -468,7 +518,12 @@ class EmailNotificationService:
'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
)
self._notify_admin(
'tier_downgrade',
f"Subscription downgraded: {old_tier} → {new_tier}",
f"<h2>Subscription Downgraded</h2><p>User ID {user_id} downgraded from <b>{old_tier}</b> to <b>{new_tier}</b>.</p>"
)
async def notify_subscription_cancelled(self, user_id: int, tier: str, end_date: str):
"""Send subscription cancelled notification"""
await self.send_notification(
......@@ -480,3 +535,25 @@ class EmailNotificationService:
'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
)
self._notify_admin(
'subscription_expired',
f"Subscription expired/cancelled: {tier}",
f"<h2>Subscription Cancelled</h2><p>User ID {user_id} subscription <b>{tier}</b> was cancelled (expires {end_date}).</p>"
)
async def notify_subscription_renewed(self, user_id: int, tier: str, new_end_date: str):
"""Send subscription renewed notification"""
await self.send_notification(
user_id,
self.SUBSCRIPTION_RENEWED,
{
'tier': tier,
'new_end_date': new_end_date,
'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
)
self._notify_admin(
'subscription_renewed',
f"Subscription renewed: {tier}",
f"<h2>Subscription Renewed</h2><p>User ID {user_id} renewed <b>{tier}</b> subscription (next renewal: {new_end_date}).</p>"
)
......@@ -582,6 +582,60 @@ _MUST_CHANGE_PASSWORD_WHITELIST = (
# --- Login rate limiter ---
# Keyed by (ip, username); value is list of failure timestamps.
def _get_admin_notifications_config() -> dict:
"""Return the dashboard.notifications dict from config, with all defaults."""
defaults = {
'new_user_signup': False,
'payment_received': False,
'tier_upgrade': False,
'tier_downgrade': False,
'subscription_expired': False,
'subscription_renewed': False,
'wallet_topup': False,
'user_deleted_account': False,
}
try:
if config and config.aisbf and hasattr(config.aisbf, 'dashboard') and config.aisbf.dashboard:
d = config.aisbf.dashboard
notif = getattr(d, 'notifications', None)
if notif:
notif_dict = notif if isinstance(notif, dict) else vars(notif)
defaults.update({k: bool(v) for k, v in notif_dict.items() if k in defaults})
except Exception:
pass
return defaults
def _get_admin_email() -> str:
"""Return the admin email from dashboard config, or empty string."""
try:
if config and config.aisbf and hasattr(config.aisbf, 'dashboard') and config.aisbf.dashboard:
return getattr(config.aisbf.dashboard, 'email', '') or ''
except Exception:
pass
return ''
def _send_admin_notification_email(event_key: str, subject: str, body_html: str):
"""Send an email to the admin if that notification type is enabled and email is configured."""
try:
notif_cfg = _get_admin_notifications_config()
if not notif_cfg.get(event_key, False):
return
admin_email = _get_admin_email()
if not admin_email:
return
smtp_cfg = None
if config and config.aisbf and hasattr(config.aisbf, 'smtp'):
smtp_cfg = config.aisbf.smtp
if not smtp_cfg or not getattr(smtp_cfg, 'enabled', False):
return
from aisbf.email_utils import send_simple_email
send_simple_email(admin_email, subject, body_html, smtp_cfg)
except Exception as e:
logger.warning(f"_send_admin_notification_email({event_key}): {e}")
_login_failures: dict = {}
_LOGIN_MAX_ATTEMPTS = 10 # failures before lockout
_LOGIN_WINDOW_SECS = 300 # 5-minute sliding window
......@@ -2853,6 +2907,14 @@ async def dashboard_signup(
# Create user
user_id = db.create_user(username=username, password_hash=password_hash, role='user', email=email, email_verified=False)
# Notify admin about new signup
_send_admin_notification_email(
'new_user_signup',
f"New user signup: {username}",
f"<h2>New User Signup</h2><p>A new user has registered on your AISBF instance.</p>"
f"<ul><li><b>Username:</b> {username}</li><li><b>Email:</b> {email or '(none)'}</li></ul>"
)
# Set verification token
expires_at = datetime.now() + timedelta(hours=24)
db.set_verification_token(user_id, verification_token, expires_at)
......@@ -3674,12 +3736,21 @@ async def dashboard_delete_account_confirm(request: Request, password: str = For
if not db.verify_user_password(user_id, password):
return RedirectResponse(url=url_for(request, "/dashboard/delete-account?error=Incorrect password"), status_code=303)
username = request.session.get('username', f'user #{user_id}')
# Delete user (this will cascade delete all related data)
db.delete_user(user_id)
# Notify admin
_send_admin_notification_email(
'user_deleted_account',
f"User deleted account: {username}",
f"<h2>User Deleted Account</h2><p>User <b>{username}</b> (ID {user_id}) has deleted their account.</p>"
)
# Clear session
request.session.clear()
return RedirectResponse(url=url_for(request, "/dashboard/login?message=Account deleted successfully"), status_code=303)
except Exception as e:
logger.error(f"Account deletion error: {e}")
......@@ -5570,7 +5641,18 @@ async def dashboard_settings_save(
oauth2_github_enabled: bool = Form(False),
oauth2_github_client_id: str = Form(""),
oauth2_github_client_secret: str = Form(""),
smtp_enabled: bool = Form(False)
smtp_enabled: bool = Form(False),
dashboard_email: str = Form(""),
admin_notify_new_user_signup: bool = Form(False),
admin_notify_payment_received: bool = Form(False),
admin_notify_tier_upgrade: bool = Form(False),
admin_notify_tier_downgrade: bool = Form(False),
admin_notify_subscription_expired: bool = Form(False),
admin_notify_subscription_renewed: bool = Form(False),
admin_notify_wallet_topup: bool = Form(False),
admin_notify_user_deleted_account: bool = Form(False),
new_admin_password: str = Form(""),
confirm_admin_password: str = Form("")
):
"""Save server settings"""
auth_check = require_admin(request)
......@@ -5722,7 +5804,32 @@ async def dashboard_settings_save(
elif 'client_secret' not in aisbf_config['oauth2']['github']:
aisbf_config['oauth2']['github']['client_secret'] = ""
aisbf_config['oauth2']['github']['scopes'] = ["user:email", "read:user"]
# Update admin email and notification preferences
if 'dashboard' not in aisbf_config:
aisbf_config['dashboard'] = {}
if dashboard_email:
aisbf_config['dashboard']['email'] = dashboard_email
elif 'email' not in aisbf_config['dashboard']:
aisbf_config['dashboard']['email'] = ""
if 'notifications' not in aisbf_config['dashboard']:
aisbf_config['dashboard']['notifications'] = {}
aisbf_config['dashboard']['notifications']['new_user_signup'] = admin_notify_new_user_signup
aisbf_config['dashboard']['notifications']['payment_received'] = admin_notify_payment_received
aisbf_config['dashboard']['notifications']['tier_upgrade'] = admin_notify_tier_upgrade
aisbf_config['dashboard']['notifications']['tier_downgrade'] = admin_notify_tier_downgrade
aisbf_config['dashboard']['notifications']['subscription_expired'] = admin_notify_subscription_expired
aisbf_config['dashboard']['notifications']['subscription_renewed'] = admin_notify_subscription_renewed
aisbf_config['dashboard']['notifications']['wallet_topup'] = admin_notify_wallet_topup
aisbf_config['dashboard']['notifications']['user_deleted_account'] = admin_notify_user_deleted_account
# Handle new_admin_password from the Admin tab (distinct from dashboard_password in Dashboard tab)
if new_admin_password:
if new_admin_password == confirm_admin_password:
aisbf_config['dashboard']['password'] = _db_hash_password(new_admin_password)
request.session.pop('must_change_password', None)
# silently ignore mismatch — UI should validate
# Save config
config_path = Path.home() / '.aisbf' / 'aisbf.json'
config_path.parent.mkdir(parents=True, exist_ok=True)
......@@ -6046,6 +6153,90 @@ async def dashboard_users_bulk(request: Request):
logger.error(f"Bulk operation error: {e}")
return JSONResponse({"success": False, "error": "Internal server error"}, status_code=500)
@app.post("/dashboard/api/admin/notifications/send")
async def admin_send_notification(request: Request):
"""Admin sends an in-app notification to selected users."""
auth_check = require_admin(request)
if auth_check:
return auth_check
db = DatabaseRegistry.get_config_database()
try:
body = await request.json()
user_ids = body.get('user_ids', [])
title = (body.get('title') or '').strip()
message = (body.get('message') or '').strip()
if not user_ids or not title or not message:
return JSONResponse({"success": False, "error": "user_ids, title and message are required"}, status_code=400)
if not isinstance(user_ids, list) or not all(isinstance(uid, int) for uid in user_ids):
return JSONResponse({"success": False, "error": "user_ids must be a list of integers"}, status_code=400)
sent = 0
for uid in user_ids:
try:
db.create_notification(uid, title, message, 'admin_message')
sent += 1
except Exception:
pass
return JSONResponse({"success": True, "sent": sent})
except Exception as e:
logger.error(f"admin_send_notification: {e}")
return JSONResponse({"success": False, "error": "Internal server error"}, status_code=500)
@app.get("/dashboard/api/notifications")
async def get_notifications(request: Request):
"""Return in-app notifications for the current user."""
if not request.session.get('logged_in') or not request.session.get('user_id'):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
user_id = request.session['user_id']
db = DatabaseRegistry.get_config_database()
notifications = db.get_user_notifications(user_id, limit=50)
return JSONResponse({"notifications": notifications})
@app.get("/dashboard/api/notifications/count")
async def get_notification_count(request: Request):
"""Return unread notification count for the current user."""
if not request.session.get('logged_in') or not request.session.get('user_id'):
return JSONResponse({"count": 0})
user_id = request.session['user_id']
db = DatabaseRegistry.get_config_database()
count = db.get_unread_notification_count(user_id)
return JSONResponse({"count": count})
@app.post("/dashboard/api/notifications/{notification_id}/read")
async def mark_notification_read(request: Request, notification_id: int):
"""Mark a single notification as read."""
if not request.session.get('logged_in') or not request.session.get('user_id'):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
user_id = request.session['user_id']
db = DatabaseRegistry.get_config_database()
db.mark_notification_read(notification_id, user_id)
return JSONResponse({"success": True})
@app.post("/dashboard/api/notifications/read-all")
async def mark_all_notifications_read(request: Request):
"""Mark all notifications as read for the current user."""
if not request.session.get('logged_in') or not request.session.get('user_id'):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
user_id = request.session['user_id']
db = DatabaseRegistry.get_config_database()
db.mark_all_notifications_read(user_id)
return JSONResponse({"success": True})
@app.delete("/dashboard/api/notifications/{notification_id}")
async def delete_notification(request: Request, notification_id: int):
"""Delete a notification for the current user."""
if not request.session.get('logged_in') or not request.session.get('user_id'):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
user_id = request.session['user_id']
db = DatabaseRegistry.get_config_database()
db.delete_notification(notification_id, user_id)
return JSONResponse({"success": True})
@app.post("/dashboard/restart")
async def dashboard_restart(request: Request):
"""Reload configuration from disk"""
......
......@@ -64,6 +64,32 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
.nav .account-dropdown a:first-child { border-radius: 8px 8px 0 0; }
.nav .account-dropdown a:last-child { border-radius: 0 0 8px 8px; }
.nav .account-dropdown a:hover { background: #0f3460; color: #e0e0e0; }
/* Notification bell */
.notif-bell { position: relative; display: flex; align-items: center; padding: 6px 10px; border-radius: 4px; cursor: pointer; color: #a0a0a0; user-select: none; }
.notif-bell:hover { background: #0f3460; color: #e0e0e0; }
.notif-badge { position: absolute; top: 2px; right: 2px; background: #e94560; color: #fff; border-radius: 50%; font-size: 10px; font-weight: 700; min-width: 16px; height: 16px; line-height: 16px; text-align: center; padding: 0 3px; display: none; }
.notif-dropdown { position: absolute; top: 100%; right: 0; background: #16213e; border: 1px solid #0f3460; border-radius: 8px; min-width: 320px; max-width: 360px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); margin-top: 8px; z-index: 200; display: none; }
.notif-dropdown.active { display: block; }
.notif-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid #0f3460; }
.notif-header span { font-weight: 600; color: #e0e0e0; font-size: 13px; }
.notif-header button { background: none; border: none; color: #60a5fa; font-size: 12px; cursor: pointer; padding: 0; }
.notif-header button:hover { color: #93c5fd; }
.notif-list { max-height: 340px; overflow-y: auto; }
.notif-item { padding: 12px 16px; border-bottom: 1px solid #0f3460; cursor: pointer; transition: background .15s; display: flex; gap: 10px; align-items: flex-start; }
.notif-item:hover { background: #0f3460; }
.notif-item.unread { border-left: 3px solid #e94560; }
.notif-item.read { border-left: 3px solid transparent; opacity: .75; }
.notif-item-body { flex: 1; min-width: 0; }
.notif-item-title { font-size: 13px; font-weight: 600; color: #e0e0e0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.notif-item-msg { font-size: 12px; color: #a0a0a0; margin-top: 3px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.notif-item-time { font-size: 11px; color: #666; margin-top: 4px; }
.notif-item-del { color: #666; font-size: 14px; padding: 2px 6px; border-radius: 3px; flex-shrink: 0; }
.notif-item-del:hover { color: #f87171; background: rgba(239,68,68,.15); }
.notif-empty { padding: 24px 16px; text-align: center; color: #666; font-size: 13px; }
.notif-footer { padding: 10px 16px; border-top: 1px solid #0f3460; text-align: center; }
.notif-footer a { color: #60a5fa; font-size: 12px; text-decoration: none; }
.notif-footer a:hover { color: #93c5fd; }
/* Rainbow Upgrade Button */
.upgrade-button {
......@@ -128,11 +154,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
dropdown.classList.toggle('active');
}
// Close dropdown when clicking outside
// Close account dropdown when clicking outside
document.addEventListener('click', function(event) {
const accountMenu = document.querySelector('.account-menu');
const dropdown = document.getElementById('account-dropdown');
if (accountMenu && dropdown && !accountMenu.contains(event.target)) {
if (!dropdown) return;
const accountMenu = dropdown.closest('.account-menu');
if (accountMenu && !accountMenu.contains(event.target)) {
dropdown.classList.remove('active');
}
});
......@@ -502,6 +529,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
}
})();
</script>
{% block extra_css %}{% endblock %}
</head>
<body>
<div class="header">
......@@ -564,6 +592,25 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<a href="{{ url_for(request, '/dashboard/pricing') }}" class="upgrade-button rainbow-text">✨ Upgrade! ✨</a>
{% endif %}
{% if request.session.user_id %}
<!-- Notification Bell -->
<div class="notif-menu-wrap" id="notif-menu" style="position:relative; display:flex; align-items:center; margin-left:0;">
<div class="notif-bell" id="notif-bell" onclick="toggleNotifDropdown(event)" title="Notifications">
<i class="fas fa-bell"></i>
<span class="notif-badge" id="notif-badge"></span>
</div>
<div class="notif-dropdown" id="notif-dropdown">
<div class="notif-header">
<span>Notifications</span>
<button onclick="markAllNotifRead(event)">Mark all as read</button>
</div>
<div class="notif-list" id="notif-list">
<div class="notif-empty">Loading…</div>
</div>
<div class="notif-footer">
<a href="#" onclick="loadNotifications(); return false;">Refresh</a>
</div>
</div>
</div>
<div class="account-menu">
<div class="account-trigger" onclick="toggleAccountMenu()">
<img src="{{ '/dashboard/profile-pic' if request.session.has_profile_pic else 'https://www.gravatar.com/avatar/' ~ (request.session.email|md5 if request.session.email else '') ~ '?s=48&d=identicon' }}" alt="User avatar">
......@@ -1073,5 +1120,129 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</script>
{% block extra_js %}{% endblock %}
{% if request.session.user_id %}
<script>
(function() {
let _notifData = [];
let _pollInterval = null;
function fmtTime(ts) {
if (!ts) return '';
const d = new Date(ts.replace(' ', 'T') + 'Z');
const now = new Date();
const diff = Math.floor((now - d) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff/60) + 'm ago';
if (diff < 86400) return Math.floor(diff/3600) + 'h ago';
return Math.floor(diff/86400) + 'd ago';
}
function renderNotifications(items) {
const list = document.getElementById('notif-list');
if (!items.length) {
list.innerHTML = '<div class="notif-empty"><i class="fas fa-check-circle" style="color:#4ade80;"></i> No notifications</div>';
return;
}
list.innerHTML = items.map(n => `
<div class="notif-item ${n.is_read ? 'read' : 'unread'}" data-id="${n.id}" onclick="readNotif(${n.id}, this)">
<div class="notif-item-body">
<div class="notif-item-title">${escHtml(n.title)}</div>
<div class="notif-item-msg">${escHtml(n.message)}</div>
<div class="notif-item-time">${fmtTime(n.created_at)}</div>
</div>
<span class="notif-item-del" title="Delete" onclick="delNotif(event, ${n.id})"><i class="fas fa-times"></i></span>
</div>
`).join('');
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function updateBadge(count) {
const badge = document.getElementById('notif-badge');
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = 'block';
} else {
badge.style.display = 'none';
}
}
window.loadNotifications = async function() {
try {
const r = await fetch('/dashboard/api/notifications');
const data = await r.json();
_notifData = data.notifications || [];
renderNotifications(_notifData);
updateBadge(_notifData.filter(n => !n.is_read).length);
} catch(e) {}
};
window.toggleNotifDropdown = function(e) {
e.stopPropagation();
const dd = document.getElementById('notif-dropdown');
const isOpen = dd.classList.contains('active');
// Close account dropdown if open
document.getElementById('account-dropdown')?.classList.remove('active');
dd.classList.toggle('active');
if (!isOpen) loadNotifications();
};
window.readNotif = async function(id, el) {
if (!el.classList.contains('unread')) return;
el.classList.replace('unread', 'read');
try {
await fetch('/dashboard/api/notifications/' + id + '/read', {method:'POST'});
const unread = document.querySelectorAll('.notif-item.unread').length;
updateBadge(unread);
} catch(e) {}
};
window.delNotif = async function(e, id) {
e.stopPropagation();
const item = document.querySelector('.notif-item[data-id="' + id + '"]');
if (item) item.remove();
try {
await fetch('/dashboard/api/notifications/' + id, {method:'DELETE'});
const remaining = document.querySelectorAll('.notif-item').length;
if (!remaining) renderNotifications([]);
const unread = document.querySelectorAll('.notif-item.unread').length;
updateBadge(unread);
} catch(e) {}
};
window.markAllNotifRead = async function(e) {
e.stopPropagation();
document.querySelectorAll('.notif-item.unread').forEach(el => el.classList.replace('unread', 'read'));
updateBadge(0);
try { await fetch('/dashboard/api/notifications/read-all', {method:'POST'}); } catch(e) {}
};
// Close notification dropdown when clicking outside
document.addEventListener('click', function(ev) {
const menu = document.getElementById('notif-menu');
const dd = document.getElementById('notif-dropdown');
if (menu && dd && !menu.contains(ev.target)) {
dd.classList.remove('active');
}
});
// Poll unread count every 60 seconds
async function pollCount() {
try {
const r = await fetch('/dashboard/api/notifications/count');
const data = await r.json();
updateBadge(data.count || 0);
} catch(e) {}
}
pollCount();
_pollInterval = setInterval(pollCount, 60000);
})();
</script>
{% endif %}
</body>
</html>
......@@ -876,19 +876,75 @@ brew services restart tor # macOS</code></pre>
</div><!-- /tab-ratelimit -->
<div class="settings-section" id="tab-admin">
<div class="section-title"><i class="fas fa-shield-alt"></i> Admin Password</div>
<div class="section-title"><i class="fas fa-shield-alt"></i> Admin Account &amp; Notifications</div>
<div class="form-group">
<label for="new_admin_password">New Admin Password</label>
<input type="password" id="new_admin_password" name="new_admin_password" placeholder="Leave blank to keep current password">
<small style="color: #666; display: block; margin-top: 5px;">Enter a new password to change the admin dashboard password</small>
</div>
<div class="form-group">
<label for="confirm_admin_password">Confirm New Admin Password</label>
<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 class="form-group">
<label for="dashboard_email">Admin Email Address</label>
<input type="email" id="dashboard_email" name="dashboard_email"
value="{{ config.dashboard.email if config.dashboard and config.dashboard.email else '' }}"
placeholder="admin@example.com">
<small style="color: #666; display: block; margin-top: 5px;">Email address to receive admin notifications. Requires SMTP to be configured and enabled.</small>
</div>
<div class="section-title" style="margin-top: 24px;"><i class="fas fa-bell"></i> Admin Email Notifications</div>
<small style="color: #666; display: block; margin-bottom: 16px;">Select which events trigger an email to the admin. SMTP must be enabled and an admin email must be set above.</small>
{% set notif = config.dashboard.notifications if config.dashboard and config.dashboard.notifications else {} %}
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px;">
<label style="display:flex; align-items:center; gap:10px; cursor:pointer; background:#0f3460; padding:12px; border-radius:6px;">
<input type="checkbox" name="admin_notify_new_user_signup"
{% if notif.new_user_signup %}checked{% endif %} style="width:auto;">
<span><i class="fas fa-user-plus" style="color:#4ade80;"></i> New user self-registered</span>
</label>
<label style="display:flex; align-items:center; gap:10px; cursor:pointer; background:#0f3460; padding:12px; border-radius:6px;">
<input type="checkbox" name="admin_notify_payment_received"
{% if notif.payment_received %}checked{% endif %} style="width:auto;">
<span><i class="fas fa-credit-card" style="color:#60a5fa;"></i> Payment received</span>
</label>
<label style="display:flex; align-items:center; gap:10px; cursor:pointer; background:#0f3460; padding:12px; border-radius:6px;">
<input type="checkbox" name="admin_notify_tier_upgrade"
{% if notif.tier_upgrade %}checked{% endif %} style="width:auto;">
<span><i class="fas fa-arrow-up" style="color:#f59e0b;"></i> Subscription upgrade</span>
</label>
<label style="display:flex; align-items:center; gap:10px; cursor:pointer; background:#0f3460; padding:12px; border-radius:6px;">
<input type="checkbox" name="admin_notify_tier_downgrade"
{% if notif.tier_downgrade %}checked{% endif %} style="width:auto;">
<span><i class="fas fa-arrow-down" style="color:#f87171;"></i> Subscription downgrade</span>
</label>
<label style="display:flex; align-items:center; gap:10px; cursor:pointer; background:#0f3460; padding:12px; border-radius:6px;">
<input type="checkbox" name="admin_notify_subscription_expired"
{% if notif.subscription_expired %}checked{% endif %} style="width:auto;">
<span><i class="fas fa-calendar-times" style="color:#f87171;"></i> Subscription expired / cancelled</span>
</label>
<label style="display:flex; align-items:center; gap:10px; cursor:pointer; background:#0f3460; padding:12px; border-radius:6px;">
<input type="checkbox" name="admin_notify_subscription_renewed"
{% if notif.subscription_renewed %}checked{% endif %} style="width:auto;">
<span><i class="fas fa-sync" style="color:#4ade80;"></i> Subscription renewed</span>
</label>
<label style="display:flex; align-items:center; gap:10px; cursor:pointer; background:#0f3460; padding:12px; border-radius:6px;">
<input type="checkbox" name="admin_notify_wallet_topup"
{% if notif.wallet_topup %}checked{% endif %} style="width:auto;">
<span><i class="fas fa-wallet" style="color:#a78bfa;"></i> Wallet top-up</span>
</label>
<label style="display:flex; align-items:center; gap:10px; cursor:pointer; background:#0f3460; padding:12px; border-radius:6px;">
<input type="checkbox" name="admin_notify_user_deleted_account"
{% if notif.user_deleted_account %}checked{% endif %} style="width:auto;">
<span><i class="fas fa-user-slash" style="color:#f87171;"></i> User deleted their account</span>
</label>
</div>
</div><!-- /tab-admin -->
<div style="display: flex; gap: 10px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #0f3460;">
......@@ -906,6 +962,17 @@ function switchTab(name) {
document.getElementById('tab-' + name).classList.add('active');
}
// Validate admin password match before form submit
document.querySelector('form').addEventListener('submit', function(e) {
const np = document.getElementById('new_admin_password').value;
const cp = document.getElementById('confirm_admin_password').value;
if (np && np !== cp) {
e.preventDefault();
switchTab('admin');
showAlert('Admin passwords do not match. Please re-enter.', 'Password Mismatch', '⚠️', 'warn');
}
});
async function testSMTP() {
const host = document.getElementById('smtp_host').value;
const port = document.getElementById('smtp_port').value;
......
......@@ -3,211 +3,265 @@
{% block title %}Usage & Quotas{% endblock %}
{% block content %}
<h2 style="margin-bottom: 8px;"><i class="fas fa-gauge-high me-2"></i>Usage &amp; Quotas</h2>
<p style="color: #a0a0a0; margin-bottom: 24px; font-size: 14px;">
Your current plan: <strong style="color: #e0e0e0;">{{ current_tier.name if current_tier else 'Free Tier' }}</strong>
{% if current_tier and not current_tier.is_default %}
&nbsp;·&nbsp; <a href="{{ url_for(request, '/dashboard/subscription') }}" style="color:#667eea; text-decoration:none;">Manage subscription</a>
{% else %}
&nbsp;·&nbsp; <a href="{{ url_for(request, '/dashboard/pricing') }}" style="color:#4a9eff; text-decoration:none;">Upgrade plan</a>
{% endif %}
</p>
<div class="usage-grid">
<!-- Plan Header -->
<div class="plan-header">
<div class="plan-header-left">
<div class="plan-badge"><i class="fas fa-layer-group"></i> Current Plan</div>
<div class="plan-name">{{ current_tier.name if current_tier else 'Free Tier' }}</div>
<div class="plan-subtitle">
{% if current_tier and not current_tier.is_default %}
<a href="{{ url_for(request, '/dashboard/subscription') }}" class="plan-link">Manage subscription</a>
{% else %}
<a href="{{ url_for(request, '/dashboard/pricing') }}" class="plan-link upgrade">Upgrade plan</a>
{% endif %}
</div>
</div>
<div class="plan-header-right">
<div class="plan-stat">
<div class="plan-stat-value">{{ "{:,}".format(requests_today) }}</div>
<div class="plan-stat-label">requests today</div>
</div>
<div class="plan-stat-divider"></div>
<div class="plan-stat">
<div class="plan-stat-value">{{ "{:,}".format(requests_month) }}</div>
<div class="plan-stat-label">requests this month</div>
</div>
<div class="plan-stat-divider"></div>
<div class="plan-stat">
<div class="plan-stat-value">
{% if tokens_24h >= 1000000 %}{{ "%.1f"|format(tokens_24h / 1000000) }}M
{% elif tokens_24h >= 1000 %}{{ "%.1f"|format(tokens_24h / 1000) }}K
{% else %}{{ tokens_24h }}{% endif %}
</div>
<div class="plan-stat-label">tokens (24h)</div>
</div>
</div>
</div>
<!-- Activity Quotas -->
<div class="section-header">
<div class="section-title"><i class="fas fa-chart-line" style="color:#60a5fa;"></i> Activity Quotas</div>
<div class="section-subtitle">Time-based limits that reset automatically</div>
</div>
<div class="activity-grid">
<!-- Requests Today -->
<div class="usage-card">
<div class="usage-card-header">
<div class="usage-icon" style="background:rgba(96,165,250,.12); color:#60a5fa;"><i class="fas fa-bolt"></i></div>
{% set daily_pct = (requests_today / max_requests_per_day * 100) if max_requests_per_day and max_requests_per_day > 0 else -1 %}
{% set daily_clr = '#ef4444' if daily_pct >= 90 else ('#fbbf24' if daily_pct >= 75 else '#60a5fa') %}
<div class="gauge-card">
<div class="gauge-header">
<div class="gauge-icon" style="background:rgba(96,165,250,.12); color:#60a5fa;"><i class="fas fa-bolt"></i></div>
<div>
<div class="usage-label">Requests Today</div>
<div class="usage-sublabel">Resets at midnight UTC</div>
<div class="gauge-label">Requests Today</div>
<div class="gauge-sublabel" id="daily-reset">Resets at midnight UTC</div>
</div>
</div>
{% set daily_pct = (requests_today / max_requests_per_day * 100) if max_requests_per_day and max_requests_per_day > 0 else -1 %}
<div class="usage-numbers">
<span class="usage-current">{{ "{:,}".format(requests_today) }}</span>
{% if max_requests_per_day == -1 %}
<span class="usage-limit">/ unlimited</span>
{% elif max_requests_per_day is not none and max_requests_per_day > 0 %}
<span class="usage-limit">/ {{ "{:,}".format(max_requests_per_day) }}</span>
<div class="gauge-wrap">
{% if max_requests_per_day == -1 or max_requests_per_day is none or max_requests_per_day <= 0 %}
<div class="gauge-unlimited" style="color:#60a5fa;"><i class="fas fa-infinity"></i></div>
{% else %}
<svg class="gauge-svg" viewBox="0 0 120 120">
<circle class="gauge-track" cx="60" cy="60" r="48"/>
<circle class="gauge-prog" cx="60" cy="60" r="48"
style="stroke:{{ daily_clr }}; stroke-dashoffset:{{ (301.59 * (1 - [daily_pct, 100]|min / 100))|round(2) }}"/>
</svg>
{% endif %}
<div class="gauge-center">
<div class="gauge-big">{{ "{:,}".format(requests_today) }}</div>
{% if max_requests_per_day and max_requests_per_day > 0 %}
<div class="gauge-of">of {{ "{:,}".format(max_requests_per_day) }}</div>
{% elif max_requests_per_day == -1 %}
<div class="gauge-of">unlimited</div>
{% endif %}
</div>
</div>
{% if max_requests_per_day and max_requests_per_day > 0 %}
<div class="progress-track">
<div class="progress-bar {{ 'warn' if daily_pct > 75 else '' }} {{ 'danger' if daily_pct > 90 else '' }}"
style="width: {{ [daily_pct, 100]|min }}%;"></div>
</div>
<div class="progress-label">
{{ "%.1f"|format([daily_pct, 100]|min) }}% used
{% if daily_pct >= 90 %}<span class="usage-badge danger">Near limit</span>{% elif daily_pct >= 75 %}<span class="usage-badge warn">Getting close</span>{% endif %}
<div class="gauge-footer">
{% if daily_pct >= 0 %}
{% set rem = max_requests_per_day - requests_today %}
{% if rem > 0 %}<span class="gauge-remaining">{{ "{:,}".format(rem) }} remaining</span>
{% else %}<span class="gauge-remaining" style="color:#ef4444;">Quota reached</span>{% endif %}
{% if daily_pct >= 90 %}<span class="usage-badge danger">Near limit</span>
{% elif daily_pct >= 75 %}<span class="usage-badge warn">Getting close</span>{% endif %}
{% else %}
<span class="gauge-remaining unlim">No daily cap</span>
{% endif %}
</div>
{% elif max_requests_per_day == -1 %}
<div class="progress-track unlimited"></div>
<div class="progress-label">Unlimited — no cap</div>
{% endif %}
</div>
<!-- Requests This Month -->
<div class="usage-card">
<div class="usage-card-header">
<div class="usage-icon" style="background:rgba(103,126,234,.12); color:#667eea;"><i class="fas fa-calendar-days"></i></div>
{% set monthly_pct = (requests_month / max_requests_per_month * 100) if max_requests_per_month and max_requests_per_month > 0 else -1 %}
{% set monthly_clr = '#ef4444' if monthly_pct >= 90 else ('#fbbf24' if monthly_pct >= 75 else '#667eea') %}
<div class="gauge-card">
<div class="gauge-header">
<div class="gauge-icon" style="background:rgba(102,126,234,.12); color:#667eea;"><i class="fas fa-calendar-days"></i></div>
<div>
<div class="usage-label">Requests This Month</div>
<div class="usage-sublabel">Resets on the 1st of each month</div>
<div class="gauge-label">Requests This Month</div>
<div class="gauge-sublabel" id="monthly-reset">Resets on the 1st</div>
</div>
</div>
{% set monthly_pct = (requests_month / max_requests_per_month * 100) if max_requests_per_month and max_requests_per_month > 0 else -1 %}
<div class="usage-numbers">
<span class="usage-current">{{ "{:,}".format(requests_month) }}</span>
{% if max_requests_per_month == -1 %}
<span class="usage-limit">/ unlimited</span>
{% elif max_requests_per_month is not none and max_requests_per_month > 0 %}
<span class="usage-limit">/ {{ "{:,}".format(max_requests_per_month) }}</span>
<div class="gauge-wrap">
{% if max_requests_per_month == -1 or max_requests_per_month is none or max_requests_per_month <= 0 %}
<div class="gauge-unlimited" style="color:#667eea;"><i class="fas fa-infinity"></i></div>
{% else %}
<svg class="gauge-svg" viewBox="0 0 120 120">
<circle class="gauge-track" cx="60" cy="60" r="48"/>
<circle class="gauge-prog" cx="60" cy="60" r="48"
style="stroke:{{ monthly_clr }}; stroke-dashoffset:{{ (301.59 * (1 - [monthly_pct, 100]|min / 100))|round(2) }}"/>
</svg>
{% endif %}
<div class="gauge-center">
<div class="gauge-big">{{ "{:,}".format(requests_month) }}</div>
{% if max_requests_per_month and max_requests_per_month > 0 %}
<div class="gauge-of">of {{ "{:,}".format(max_requests_per_month) }}</div>
{% elif max_requests_per_month == -1 %}
<div class="gauge-of">unlimited</div>
{% endif %}
</div>
</div>
{% if max_requests_per_month and max_requests_per_month > 0 %}
<div class="progress-track">
<div class="progress-bar {{ 'warn' if monthly_pct > 75 else '' }} {{ 'danger' if monthly_pct > 90 else '' }}"
style="width: {{ [monthly_pct, 100]|min }}%;"></div>
</div>
<div class="progress-label">
{{ "%.1f"|format([monthly_pct, 100]|min) }}% used
{% if monthly_pct >= 90 %}<span class="usage-badge danger">Near limit</span>{% elif monthly_pct >= 75 %}<span class="usage-badge warn">Getting close</span>{% endif %}
<div class="gauge-footer">
{% if monthly_pct >= 0 %}
{% set rem = max_requests_per_month - requests_month %}
{% if rem > 0 %}<span class="gauge-remaining">{{ "{:,}".format(rem) }} remaining</span>
{% else %}<span class="gauge-remaining" style="color:#ef4444;">Quota reached</span>{% endif %}
{% if monthly_pct >= 90 %}<span class="usage-badge danger">Near limit</span>
{% elif monthly_pct >= 75 %}<span class="usage-badge warn">Getting close</span>{% endif %}
{% else %}
<span class="gauge-remaining unlim">No monthly cap</span>
{% endif %}
</div>
{% elif max_requests_per_month == -1 %}
<div class="progress-track unlimited"></div>
<div class="progress-label">Unlimited — no cap</div>
{% endif %}
</div>
<!-- Providers -->
<div class="usage-card">
<div class="usage-card-header">
<div class="usage-icon" style="background:rgba(74,222,128,.12); color:#4ade80;"><i class="fas fa-plug"></i></div>
<!-- Tokens 24h -->
<div class="gauge-card">
<div class="gauge-header">
<div class="gauge-icon" style="background:rgba(34,211,238,.12); color:#22d3ee;"><i class="fas fa-coins"></i></div>
<div>
<div class="usage-label">AI Providers</div>
<div class="usage-sublabel">Configured provider integrations</div>
<div class="gauge-label">Tokens (last 24h)</div>
<div class="gauge-sublabel">Input + output combined</div>
</div>
</div>
{% set prov_pct = (providers_count / max_providers * 100) if max_providers and max_providers > 0 else -1 %}
<div class="usage-numbers">
<span class="usage-current">{{ providers_count }}</span>
{% if max_providers == -1 %}
<span class="usage-limit">/ unlimited</span>
{% elif max_providers is not none and max_providers > 0 %}
<span class="usage-limit">/ {{ max_providers }}</span>
{% endif %}
<div class="gauge-wrap">
<div class="gauge-unlimited" style="color:#22d3ee;"><i class="fas fa-infinity"></i></div>
<div class="gauge-center">
<div class="gauge-big" style="color:#22d3ee;">
{% if tokens_24h >= 1000000 %}{{ "%.1f"|format(tokens_24h / 1000000) }}M
{% elif tokens_24h >= 1000 %}{{ "%.1f"|format(tokens_24h / 1000) }}K
{% else %}{{ tokens_24h }}{% endif %}
</div>
<div class="gauge-of">tokens used</div>
</div>
</div>
<div class="gauge-footer">
<span class="gauge-remaining unlim">No token cap on this plan</span>
</div>
</div>
</div>
<!-- Configuration Limits -->
<div class="section-header" style="margin-top:32px;">
<div class="section-title"><i class="fas fa-sliders" style="color:#a78bfa;"></i> Configuration Limits</div>
<div class="section-subtitle">Persistent resource allocations for your account</div>
</div>
<div class="resource-grid">
<!-- AI Providers -->
{% set prov_pct = (providers_count / max_providers * 100) if max_providers and max_providers > 0 else -1 %}
<div class="resource-card">
<div class="resource-top">
<div class="resource-icon" style="background:rgba(74,222,128,.12); color:#4ade80;"><i class="fas fa-plug"></i></div>
<div class="resource-info">
<div class="resource-label">AI Providers</div>
<div class="resource-sublabel">Configured provider integrations</div>
</div>
<div class="resource-count">
<span class="resource-used">{{ providers_count }}</span>
{% if max_providers == -1 %}<span class="resource-max">/ ∞</span>
{% elif max_providers and max_providers > 0 %}<span class="resource-max">/ {{ max_providers }}</span>{% endif %}
</div>
</div>
{% if max_providers and max_providers > 0 %}
<div class="progress-track">
<div class="progress-bar {{ 'warn' if prov_pct > 75 else '' }} {{ 'danger' if prov_pct > 90 else '' }}"
style="width: {{ [prov_pct, 100]|min }}%;"></div>
<div class="resource-bar-wrap">
<div class="resource-bar" style="width:{{ [prov_pct, 100]|min }}%; background:{{ '#ef4444' if prov_pct >= 90 else ('#fbbf24' if prov_pct >= 75 else '#4ade80') }};"></div>
</div>
<div class="progress-label">{{ "%.1f"|format([prov_pct, 100]|min) }}% used</div>
{% elif max_providers == -1 %}
<div class="progress-track unlimited"></div>
<div class="progress-label">Unlimited</div>
{% set free = max_providers - providers_count %}
<div class="resource-bar-label">{{ "%.0f"|format([prov_pct, 100]|min) }}% used &middot; {{ free }} slot{{ 's' if free != 1 else '' }} free</div>
{% else %}
<div class="resource-bar-wrap unlimited-bar"></div>
<div class="resource-bar-label unlim">Unlimited slots available</div>
{% endif %}
</div>
<!-- Rotations -->
<div class="usage-card">
<div class="usage-card-header">
<div class="usage-icon" style="background:rgba(251,146,60,.12); color:#fb923c;"><i class="fas fa-rotate"></i></div>
<div>
<div class="usage-label">Rotations</div>
<div class="usage-sublabel">Load balancing configurations</div>
{% set rot_pct = (rotations_count / max_rotations * 100) if max_rotations and max_rotations > 0 else -1 %}
<div class="resource-card">
<div class="resource-top">
<div class="resource-icon" style="background:rgba(251,146,60,.12); color:#fb923c;"><i class="fas fa-rotate"></i></div>
<div class="resource-info">
<div class="resource-label">Rotations</div>
<div class="resource-sublabel">Load balancing configurations</div>
</div>
<div class="resource-count">
<span class="resource-used">{{ rotations_count }}</span>
{% if max_rotations == -1 %}<span class="resource-max">/ ∞</span>
{% elif max_rotations and max_rotations > 0 %}<span class="resource-max">/ {{ max_rotations }}</span>{% endif %}
</div>
</div>
{% set rot_pct = (rotations_count / max_rotations * 100) if max_rotations and max_rotations > 0 else -1 %}
<div class="usage-numbers">
<span class="usage-current">{{ rotations_count }}</span>
{% if max_rotations == -1 %}
<span class="usage-limit">/ unlimited</span>
{% elif max_rotations is not none and max_rotations > 0 %}
<span class="usage-limit">/ {{ max_rotations }}</span>
{% endif %}
</div>
{% if max_rotations and max_rotations > 0 %}
<div class="progress-track">
<div class="progress-bar {{ 'warn' if rot_pct > 75 else '' }} {{ 'danger' if rot_pct > 90 else '' }}"
style="width: {{ [rot_pct, 100]|min }}%;"></div>
<div class="resource-bar-wrap">
<div class="resource-bar" style="width:{{ [rot_pct, 100]|min }}%; background:{{ '#ef4444' if rot_pct >= 90 else ('#fbbf24' if rot_pct >= 75 else '#fb923c') }};"></div>
</div>
<div class="progress-label">{{ "%.1f"|format([rot_pct, 100]|min) }}% used</div>
{% elif max_rotations == -1 %}
<div class="progress-track unlimited"></div>
<div class="progress-label">Unlimited</div>
{% set free = max_rotations - rotations_count %}
<div class="resource-bar-label">{{ "%.0f"|format([rot_pct, 100]|min) }}% used &middot; {{ free }} slot{{ 's' if free != 1 else '' }} free</div>
{% else %}
<div class="resource-bar-wrap unlimited-bar"></div>
<div class="resource-bar-label unlim">Unlimited slots available</div>
{% endif %}
</div>
<!-- Autoselections -->
<div class="usage-card">
<div class="usage-card-header">
<div class="usage-icon" style="background:rgba(192,132,252,.12); color:#c084fc;"><i class="fas fa-wand-magic-sparkles"></i></div>
<div>
<div class="usage-label">Autoselections</div>
<div class="usage-sublabel">Smart routing configurations</div>
{% set auto_pct = (autoselects_count / max_autoselections * 100) if max_autoselections and max_autoselections > 0 else -1 %}
<div class="resource-card">
<div class="resource-top">
<div class="resource-icon" style="background:rgba(192,132,252,.12); color:#c084fc;"><i class="fas fa-wand-magic-sparkles"></i></div>
<div class="resource-info">
<div class="resource-label">Autoselections</div>
<div class="resource-sublabel">Smart routing configurations</div>
</div>
<div class="resource-count">
<span class="resource-used">{{ autoselects_count }}</span>
{% if max_autoselections == -1 %}<span class="resource-max">/ ∞</span>
{% elif max_autoselections and max_autoselections > 0 %}<span class="resource-max">/ {{ max_autoselections }}</span>{% endif %}
</div>
</div>
{% set auto_pct = (autoselects_count / max_autoselections * 100) if max_autoselections and max_autoselections > 0 else -1 %}
<div class="usage-numbers">
<span class="usage-current">{{ autoselects_count }}</span>
{% if max_autoselections == -1 %}
<span class="usage-limit">/ unlimited</span>
{% elif max_autoselections is not none and max_autoselections > 0 %}
<span class="usage-limit">/ {{ max_autoselections }}</span>
{% endif %}
</div>
{% if max_autoselections and max_autoselections > 0 %}
<div class="progress-track">
<div class="progress-bar {{ 'warn' if auto_pct > 75 else '' }} {{ 'danger' if auto_pct > 90 else '' }}"
style="width: {{ [auto_pct, 100]|min }}%;"></div>
<div class="resource-bar-wrap">
<div class="resource-bar" style="width:{{ [auto_pct, 100]|min }}%; background:{{ '#ef4444' if auto_pct >= 90 else ('#fbbf24' if auto_pct >= 75 else '#c084fc') }};"></div>
</div>
<div class="progress-label">{{ "%.1f"|format([auto_pct, 100]|min) }}% used</div>
{% elif max_autoselections == -1 %}
<div class="progress-track unlimited"></div>
<div class="progress-label">Unlimited</div>
{% set free = max_autoselections - autoselects_count %}
<div class="resource-bar-label">{{ "%.0f"|format([auto_pct, 100]|min) }}% used &middot; {{ free }} slot{{ 's' if free != 1 else '' }} free</div>
{% else %}
<div class="resource-bar-wrap unlimited-bar"></div>
<div class="resource-bar-label unlim">Unlimited slots available</div>
{% endif %}
</div>
<!-- Tokens (24h) -->
<div class="usage-card">
<div class="usage-card-header">
<div class="usage-icon" style="background:rgba(34,211,238,.12); color:#22d3ee;"><i class="fas fa-coins"></i></div>
<div>
<div class="usage-label">Tokens (last 24h)</div>
<div class="usage-sublabel">Total input + output tokens</div>
</div>
</div>
<div class="usage-numbers">
<span class="usage-current">
{% if tokens_24h >= 1000000 %}{{ "%.1f"|format(tokens_24h / 1000000) }}M
{% elif tokens_24h >= 1000 %}{{ "%.1f"|format(tokens_24h / 1000) }}K
{% else %}{{ tokens_24h }}{% endif %}
</span>
<span class="usage-limit">/ unlimited</span>
</div>
<div class="progress-track unlimited"></div>
<div class="progress-label">No token cap on this plan</div>
</div>
</div>
{% if upgrade_tiers %}
<!-- Upgrade CTA -->
<div style="margin-top: 24px; background: linear-gradient(135deg, rgba(74,158,255,.12), rgba(103,126,234,.08)); border: 1px solid rgba(74,158,255,.4); border-radius: 10px; padding: 20px 24px; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;">
<div>
<div style="font-size: 1rem; font-weight: 700; color: #e0e0e0; margin-bottom: 6px;">
<i class="fas fa-rocket" style="color:#4a9eff;"></i> Need higher limits?
</div>
<div style="color: #a0a0a0; font-size: 14px;">
Upgrade your plan to unlock more requests, providers, and autoselections.
<div class="upgrade-cta">
<div class="upgrade-cta-content">
<i class="fas fa-rocket upgrade-cta-icon"></i>
<div>
<div class="upgrade-cta-title">Need higher limits?</div>
<div class="upgrade-cta-sub">Upgrade your plan to unlock more requests, providers, and autoselections.</div>
</div>
</div>
<a href="{{ url_for(request, '/dashboard/pricing') }}"
style="background: linear-gradient(135deg, #4a9eff, #667eea); color: white; text-decoration: none; padding: 10px 20px; border-radius: 8px; font-weight: 600; font-size: 15px; white-space: nowrap; flex-shrink: 0;">
<i class="fas fa-arrow-up me-1"></i> View Plans
<a href="{{ url_for(request, '/dashboard/pricing') }}" class="upgrade-cta-btn">
<i class="fas fa-arrow-up"></i> View Plans
</a>
</div>
{% endif %}
......@@ -216,106 +270,255 @@
{% block extra_css %}
<style>
.usage-grid {
/* ── Plan header ─────────────────────────────────── */
.plan-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
background: linear-gradient(135deg, #16213e 0%, #0f2040 100%);
border: 1px solid #0f3460;
border-radius: 12px;
padding: 24px 28px;
margin-bottom: 28px;
flex-wrap: wrap;
}
.plan-badge {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(102,126,234,.15);
color: #667eea;
border: 1px solid rgba(102,126,234,.3);
border-radius: 20px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .5px;
padding: 4px 10px;
margin-bottom: 8px;
}
.plan-name {
font-size: 1.6rem;
font-weight: 700;
color: #e0e0e0;
line-height: 1.1;
}
.plan-subtitle { margin-top: 6px; }
.plan-link { font-size: 13px; color: #667eea; text-decoration: none; }
.plan-link:hover { color: #818cf8; }
.plan-link.upgrade { color: #4a9eff; }
.plan-link.upgrade:hover { color: #60b4ff; }
.plan-header-right {
display: flex;
align-items: center;
flex-shrink: 0;
}
.plan-stat { padding: 0 24px; text-align: center; }
.plan-stat-value { font-size: 1.5rem; font-weight: 700; color: #e0e0e0; line-height: 1; }
.plan-stat-label { font-size: 11px; color: #666; margin-top: 4px; text-transform: uppercase; letter-spacing: .4px; }
.plan-stat-divider { width: 1px; height: 40px; background: #1e3a5f; flex-shrink: 0; }
/* ── Section headers ─────────────────────────────── */
.section-header {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 14px;
border-left: 3px solid #1e3a5f;
padding-left: 12px;
}
.section-title { font-size: 1rem; font-weight: 700; color: #e0e0e0; }
.section-subtitle { font-size: 13px; color: #555; }
/* ── Activity gauge cards ────────────────────────── */
.activity-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
margin-bottom: 0;
}
.usage-card {
.gauge-card {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 10px;
border-radius: 12px;
padding: 20px;
transition: border-color .2s;
display: flex;
flex-direction: column;
gap: 4px;
transition: border-color .2s, transform .2s;
}
.gauge-card:hover { border-color: #2a4a7f; transform: translateY(-2px); }
.gauge-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.gauge-icon {
width: 36px; height: 36px;
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 1rem; flex-shrink: 0;
}
.usage-card:hover { border-color: #667eea; }
.gauge-label { font-size: .9rem; font-weight: 600; color: #e0e0e0; }
.gauge-sublabel { font-size: .72rem; color: #555; margin-top: 2px; }
.usage-card-header {
.gauge-wrap {
position: relative;
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
justify-content: center;
min-height: 140px;
margin: 8px 0;
}
.gauge-svg { width: 140px; height: 140px; }
.gauge-track { fill: none; stroke: #0f3460; stroke-width: 10; }
.gauge-prog {
fill: none;
stroke-width: 10;
stroke-linecap: round;
stroke-dasharray: 301.59;
transform: rotate(-90deg);
transform-origin: 60px 60px;
transition: stroke-dashoffset .6s ease;
}
.gauge-center { position: absolute; text-align: center; }
.gauge-big { font-size: 1.4rem; font-weight: 700; color: #e0e0e0; line-height: 1; }
.gauge-of { font-size: .72rem; color: #555; margin-top: 3px; }
.usage-icon {
width: 40px; height: 40px;
border-radius: 10px;
.gauge-unlimited {
font-size: 3rem;
opacity: .2;
display: flex; align-items: center; justify-content: center;
font-size: 1.1rem;
flex-shrink: 0;
width: 140px; height: 140px;
}
.usage-label {
font-size: .95rem;
font-weight: 600;
color: #e0e0e0;
}
.usage-sublabel {
font-size: .75rem;
color: #666;
margin-top: 2px;
.gauge-footer {
display: flex;
align-items: center;
gap: 8px;
padding-top: 10px;
border-top: 1px solid #0f3460;
min-height: 30px;
}
.gauge-remaining { font-size: .8rem; color: #a0a0a0; flex: 1; }
.gauge-remaining.unlim { color: #4ade80; font-weight: 500; }
.usage-numbers {
display: flex;
align-items: baseline;
gap: 4px;
margin-bottom: 10px;
/* ── Resource cards (config limits) ─────────────── */
.resource-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
.usage-current {
font-size: 1.8rem;
font-weight: 700;
color: #e0e0e0;
line-height: 1;
.resource-card {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 12px;
padding: 18px 20px;
transition: border-color .2s;
}
.usage-limit {
font-size: .9rem;
color: #666;
.resource-card:hover { border-color: #2a4a7f; }
.resource-top { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
.resource-icon {
width: 38px; height: 38px;
border-radius: 9px;
display: flex; align-items: center; justify-content: center;
font-size: 1rem; flex-shrink: 0;
}
.resource-info { flex: 1; min-width: 0; }
.resource-label { font-size: .9rem; font-weight: 600; color: #e0e0e0; }
.resource-sublabel { font-size: .72rem; color: #555; margin-top: 2px; }
.progress-track {
.resource-count { text-align: right; flex-shrink: 0; }
.resource-used { font-size: 1.5rem; font-weight: 700; color: #e0e0e0; line-height: 1; display: block; }
.resource-max { font-size: .78rem; color: #555; display: block; text-align: right; }
.resource-bar-wrap {
height: 6px;
background: #0f3460;
border-radius: 3px;
overflow: hidden;
margin-bottom: 6px;
}
.progress-track.unlimited {
.resource-bar-wrap.unlimited-bar {
background: repeating-linear-gradient(
90deg,
rgba(74,158,255,.15) 0px,
rgba(74,158,255,.15) 4px,
transparent 4px,
transparent 8px
rgba(74,222,128,.15) 0px, rgba(74,222,128,.15) 4px,
transparent 4px, transparent 8px
);
}
.progress-bar {
height: 100%;
background: #4a9eff;
border-radius: 3px;
transition: width .6s ease;
}
.progress-bar.warn { background: #fbbf24; }
.progress-bar.danger { background: #ef4444; }
.progress-label {
font-size: .78rem;
color: #666;
display: flex;
align-items: center;
gap: 8px;
}
.resource-bar { height: 100%; border-radius: 3px; transition: width .6s ease; }
.resource-bar-label { font-size: .75rem; color: #555; }
.resource-bar-label.unlim { color: #4ade80; font-weight: 500; }
/* ── Badges ──────────────────────────────────────── */
.usage-badge {
display: inline-block;
padding: 1px 7px;
border-radius: 10px;
font-size: .72rem;
font-size: .7rem;
font-weight: 600;
}
.usage-badge.warn { background: rgba(251,191,36,.15); color: #fbbf24; border: 1px solid rgba(251,191,36,.4); }
.usage-badge.danger { background: rgba(239,68,68,.15); color: #ef4444; border: 1px solid rgba(239,68,68,.4); }
.usage-badge.warn { background: rgba(251,191,36,.12); color: #fbbf24; border: 1px solid rgba(251,191,36,.35); }
.usage-badge.danger { background: rgba(239,68,68,.12); color: #ef4444; border: 1px solid rgba(239,68,68,.35); }
/* ── Upgrade CTA ─────────────────────────────────── */
.upgrade-cta {
margin-top: 28px;
background: linear-gradient(135deg, rgba(74,158,255,.10), rgba(103,126,234,.07));
border: 1px solid rgba(74,158,255,.35);
border-radius: 12px;
padding: 20px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.upgrade-cta-content { display: flex; align-items: center; gap: 16px; }
.upgrade-cta-icon { font-size: 1.4rem; color: #4a9eff; flex-shrink: 0; }
.upgrade-cta-title { font-size: 1rem; font-weight: 700; color: #e0e0e0; margin-bottom: 4px; }
.upgrade-cta-sub { font-size: 13px; color: #888; }
.upgrade-cta-btn {
background: linear-gradient(135deg, #4a9eff, #667eea);
color: white;
text-decoration: none;
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
font-size: 14px;
white-space: nowrap;
flex-shrink: 0;
transition: opacity .2s;
}
.upgrade-cta-btn:hover { opacity: .85; color: white; }
@media (max-width: 600px) {
.plan-header { flex-direction: column; }
.plan-header-right { flex-wrap: wrap; }
.plan-stat { padding: 8px 16px; }
}
</style>
{% endblock %}
{% block extra_js %}
<script>
(function () {
function updateTimers() {
var now = new Date();
var midnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
var ms = midnight - now;
var h = Math.floor(ms / 3600000);
var m = Math.floor((ms % 3600000) / 60000);
var el = document.getElementById('daily-reset');
if (el) el.textContent = 'Resets in ' + h + 'h ' + m + 'm';
var nextMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
var days = Math.ceil((nextMonth - now) / 86400000);
var mel = document.getElementById('monthly-reset');
if (mel) mel.textContent = 'Resets in ' + days + ' day' + (days !== 1 ? 's' : '');
}
updateTimers();
setInterval(updateTimers, 30000);
})();
</script>
{% endblock %}
......@@ -107,6 +107,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<button id="bulk-enable" class="btn">Enable Selected</button>
<button id="bulk-disable" class="btn">Disable Selected</button>
<button id="bulk-delete" class="btn btn-danger">Delete Selected</button>
<button id="bulk-notify" class="btn btn-secondary" style="background:#7c3aed;"><i class="fas fa-bell"></i> Send Notification</button>
<button id="bulk-clear" class="btn btn-secondary">Clear Selection</button>
</div>
</div>
......@@ -965,4 +966,89 @@ input:-webkit-autofill:active {
-webkit-text-fill-color: #e0e0e0 !important;
}
</style>
<!-- Send Notification Modal -->
<div id="notify-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.7); z-index:1000; align-items:center; justify-content:center;">
<div style="background:#16213e; padding:30px; border-radius:8px; width:90%; max-width:520px; border:1px solid #0f3460;">
<h3 style="margin-bottom:20px;"><i class="fas fa-bell" style="color:#a78bfa;"></i> Send Notification</h3>
<p id="notify-target-label" style="color:#a0a0a0; margin-bottom:16px; font-size:13px;"></p>
<div class="form-group">
<label for="notify-title">Title</label>
<input type="text" id="notify-title" maxlength="200" placeholder="Notification title"
style="background:#1a1a2e; color:#e0e0e0; border:1px solid #0f3460; padding:8px; border-radius:3px; width:100%;">
</div>
<div class="form-group">
<label for="notify-message">Message</label>
<textarea id="notify-message" rows="4" placeholder="Notification message…"
style="background:#1a1a2e; color:#e0e0e0; border:1px solid #0f3460; padding:8px; border-radius:3px; width:100%; resize:vertical;"></textarea>
</div>
<div style="display:flex; gap:10px; margin-top:10px;">
<button class="btn" onclick="submitNotification()"><i class="fas fa-paper-plane"></i> Send</button>
<button class="btn btn-secondary" onclick="closeNotifyModal()">Cancel</button>
</div>
<div id="notify-status" style="margin-top:12px;"></div>
</div>
</div>
<script>
let _notifyUserIds = [];
function openNotifyModal(userIds) {
_notifyUserIds = userIds;
document.getElementById('notify-title').value = '';
document.getElementById('notify-message').value = '';
document.getElementById('notify-status').innerHTML = '';
document.getElementById('notify-target-label').textContent =
`Sending to ${userIds.length} user${userIds.length !== 1 ? 's' : ''}.`;
const modal = document.getElementById('notify-modal');
modal.style.display = 'flex';
}
function closeNotifyModal() {
document.getElementById('notify-modal').style.display = 'none';
}
async function submitNotification() {
const title = document.getElementById('notify-title').value.trim();
const message = document.getElementById('notify-message').value.trim();
const statusEl = document.getElementById('notify-status');
if (!title || !message) {
statusEl.innerHTML = '<div class="alert alert-error">Title and message are required.</div>';
return;
}
try {
const res = await fetch('/dashboard/api/admin/notifications/send', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({user_ids: _notifyUserIds, title, message})
});
const data = await res.json();
if (data.success) {
statusEl.innerHTML = `<div class="alert alert-success">Sent to ${data.sent} user(s).</div>`;
setTimeout(closeNotifyModal, 1500);
} else {
statusEl.innerHTML = `<div class="alert alert-error">${data.error || 'Failed to send.'}</div>`;
}
} catch (e) {
statusEl.innerHTML = '<div class="alert alert-error">Network error.</div>';
}
}
document.getElementById('notify-modal').addEventListener('click', function(e) {
if (e.target === this) closeNotifyModal();
});
// Wire "Send Notification" bulk button
document.addEventListener('DOMContentLoaded', function() {
const notifyBtn = document.getElementById('bulk-notify');
if (notifyBtn) {
notifyBtn.addEventListener('click', function() {
const checked = Array.from(document.querySelectorAll('.user-checkbox:checked'))
.map(cb => parseInt(cb.value));
if (!checked.length) return;
openNotifyModal(checked);
});
}
});
</script>
{% endblock %}
\ No newline at end of file
......@@ -398,13 +398,17 @@ document.addEventListener('DOMContentLoaded', function () {
refunded: 'background:#6c757d;color:white;',
};
const txLoading = document.getElementById('tx-loading');
fetch('/dashboard/wallet/transactions')
.then(r => r.json())
.then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(transactions => {
const list = document.getElementById('transaction-list');
document.getElementById('tx-loading').remove();
if (txLoading && txLoading.parentNode) txLoading.remove();
if (!transactions || transactions.length === 0) {
if (!Array.isArray(transactions) || transactions.length === 0) {
list.innerHTML = `<tr><td colspan="5" style="padding:40px;text-align:center;color:#a0a0a0;">
<i class="fas fa-receipt fa-3x" style="margin-bottom:12px;display:block;"></i>
No transactions yet.
......@@ -442,8 +446,10 @@ document.addEventListener('DOMContentLoaded', function () {
});
})
.catch(() => {
document.getElementById('tx-loading').innerHTML =
'<td colspan="5" style="padding:30px;text-align:center;color:#f87171;">Failed to load transactions.</td>';
const list = document.getElementById('transaction-list');
if (txLoading && txLoading.parentNode) txLoading.remove();
if (list) list.innerHTML =
'<tr><td colspan="5" style="padding:30px;text-align:center;color:#f87171;">Failed to load transactions.</td></tr>';
});
});
......
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