0.99.51

parent 8c425f1d
......@@ -96,12 +96,12 @@ curl -X POST http://localhost:17765/api/wallet/topup \
## Documentation
For complete documentation, configuration guides, and API reference:
- **[📚 Full Documentation](DOCUMENTATION.md)** - Comprehensive user and developer guide
- **[🔧 Installation Guide](DOCUMENTATION.md#installation)** - Detailed setup instructions
- **[⚙️ Configuration](DOCUMENTATION.md#configuration)** - All configuration options
- **[💰 Wallet System](DOCUMENTATION.md#wallet-system)** - Complete wallet documentation
- **[🔌 API Reference](DOCUMENTATION.md#api-endpoints)** - Complete API documentation
- **[🛠️ Development](DOCUMENTATION.md#development)** - Development and deployment guides
- **[📚 Full Documentation](https://git.nexlab.net/nexlab/aisbf/src/master/DOCUMENTATION.md)** - Comprehensive user and developer guide
- **[🔧 Installation Guide](https://git.nexlab.net/nexlab/aisbf/src/master/DOCUMENTATION.md#installation)** - Detailed setup instructions
- **[⚙️ Configuration](https://git.nexlab.net/nexlab/aisbf/src/master/DOCUMENTATION.md#configuration)** - All configuration options
- **[💰 Wallet System](https://git.nexlab.net/nexlab/aisbf/src/master/DOCUMENTATION.md#wallet-system)** - Complete wallet documentation
- **[🔌 API Reference](https://git.nexlab.net/nexlab/aisbf/src/master/DOCUMENTATION.md#api-endpoints)** - Complete API documentation
- **[🛠️ Development](https://git.nexlab.net/nexlab/aisbf/src/master/DOCUMENTATION.md#development)** - Development and deployment guides
## 🚀 Support AISBF - Your Donations Matter!
......
......@@ -200,6 +200,45 @@ class StripePaymentHandler:
logger.error(f"Error creating Stripe top up intent: {e}")
return {'success': False, 'error': str(e)}
async def create_topup_checkout_session(self, user_id: int, amount: Decimal, success_url: str, cancel_url: str) -> dict:
"""Create a Stripe Checkout Session for wallet top-up (hosted redirect flow)."""
try:
customer_id = await self._get_or_create_customer(user_id)
currency = self.config.get('currency_code', 'usd').lower()
session = await asyncio.to_thread(
stripe.checkout.Session.create,
customer=customer_id,
payment_method_types=['card'],
line_items=[{
'price_data': {
'currency': currency,
'product_data': {'name': 'Wallet Top-Up'},
'unit_amount': int(amount * 100),
},
'quantity': 1,
}],
mode='payment',
success_url=success_url,
cancel_url=cancel_url,
metadata={
'user_id': str(user_id),
'topup': 'true',
'amount': str(amount),
},
payment_intent_data={
'metadata': {
'user_id': str(user_id),
'topup': 'true',
'amount': str(amount),
},
},
)
logger.info(f"Created Stripe checkout session for user {user_id}: {session.id}")
return {'success': True, 'checkout_url': session.url, 'session_id': session.id}
except Exception as e:
logger.error(f"Error creating Stripe checkout session: {e}")
return {'success': False, 'error': str(e)}
async def _handle_payment_succeeded(self, payment_intent: dict):
"""Handle successful Stripe payment — credits user wallet for top-up intents."""
logger.info(f"Payment succeeded: {payment_intent['id']}")
......
......@@ -1362,8 +1362,9 @@ async def startup_event():
if not encryption_key:
encryption_key = Fernet.generate_key().decode()
encryption_key_source = 'temporary'
logger.warning("No ENCRYPTION_KEY set in database or environment, generated temporary key")
encryption_key_source = 'generated'
db_manager.save_encryption_key(encryption_key)
logger.warning("No ENCRYPTION_KEY set in database or environment, generated and saved new key to database")
else:
logger.info(f"Loaded ENCRYPTION_KEY from {encryption_key_source}")
......@@ -2462,6 +2463,42 @@ async def dashboard_analytics(
}
)
@app.get("/dashboard/api/auth-check")
async def dashboard_auth_check(request: Request):
"""Return authentication status for OAuth popup polling."""
from fastapi.responses import JSONResponse
authenticated = bool(request.session.get('logged_in') and request.session.get('user_id'))
return JSONResponse({"authenticated": authenticated})
@app.get("/dashboard/profile-pic")
async def dashboard_profile_pic(request: Request):
"""Serve the logged-in user's profile picture from the database."""
from fastapi.responses import Response
user_id = request.session.get('user_id')
if not user_id:
return Response(status_code=404)
try:
db = DatabaseRegistry.get_config_database()
user = db.get_user_by_id(user_id)
pic = user.get('profile_pic') if user else None
if not pic:
return Response(status_code=404)
# pic is stored as a data URL: "data:<mime>;base64,<data>"
if pic.startswith('data:'):
header, b64data = pic.split(',', 1)
mime = header.split(':')[1].split(';')[0]
import base64
img_bytes = base64.b64decode(b64data)
return Response(content=img_bytes, media_type=mime,
headers={"Cache-Control": "private, max-age=3600"})
# If it's a plain URL, redirect
from fastapi.responses import RedirectResponse
return RedirectResponse(url=pic)
except Exception:
return Response(status_code=404)
@app.get("/dashboard/login", response_class=HTMLResponse)
async def dashboard_login_page(request: Request):
"""Show dashboard login page"""
......@@ -2581,7 +2618,7 @@ async def dashboard_login(request: Request, username: str = Form(...), password:
request.session['email'] = user.get('email') or ''
request.session['role'] = user['role']
request.session['user_id'] = user['id']
request.session['profile_pic'] = user.get('profile_pic') or ''
request.session['has_profile_pic'] = bool(user.get('profile_pic'))
request.session['remember_me'] = remember_me
request.session['email_verified'] = user['email_verified']
if remember_me:
......@@ -3357,7 +3394,7 @@ async def dashboard_profile_pic_chunk(
db = DatabaseRegistry.get_config_database()
db.update_user_profile(user_id, request.session.get('username', ''), None, None, data_url)
request.session['profile_pic'] = data_url
request.session['has_profile_pic'] = True
return JSONResponse({"success": True, "complete": True})
......@@ -3767,7 +3804,7 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state
request.session['email'] = existing_user.get('email', '')
request.session['role'] = existing_user['role']
request.session['user_id'] = existing_user['id']
request.session['profile_pic'] = existing_user.get('profile_pic') or ''
request.session['has_profile_pic'] = bool(existing_user.get('profile_pic'))
request.session['email_verified'] = True # OAuth2 users have verified emails
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
......@@ -3827,21 +3864,24 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state
<head><title>Authentication Complete</title></head>
<body>
<script>
if (window.opener) {{
window.opener.postMessage({{
type: 'oauth2_complete',
redirect_url: '{url_for(request, "/dashboard")}'
}}, '*');
}}
var msg = {{ type: 'oauth2_complete', redirect_url: '{url_for(request, "/dashboard")}' }};
try {{
var bc = new BroadcastChannel('oauth2_result');
bc.postMessage(msg);
bc.close();
}} catch(e) {{}}
try {{
if (window.opener) window.opener.postMessage(msg, '*');
}} catch(e) {{}}
window.close();
</script>
</body>
</html>
''')
# Redirect to dashboard for regular requests
return RedirectResponse(url=url_for(request, "/dashboard"), status_code=303)
except Exception as e:
logger.error(f"Error during email verification: {e}", exc_info=True)
return templates.TemplateResponse(
......@@ -3959,7 +3999,7 @@ async def oauth2_github_callback(request: Request, code: str = Query(...), state
request.session['email'] = existing_user.get('email', '')
request.session['role'] = existing_user['role']
request.session['user_id'] = existing_user['id']
request.session['profile_pic'] = existing_user.get('profile_pic') or ''
request.session['has_profile_pic'] = bool(existing_user.get('profile_pic'))
request.session['email_verified'] = True # OAuth2 users have verified emails
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
......@@ -4004,28 +4044,30 @@ async def oauth2_github_callback(request: Request, code: str = Query(...), state
# Cleanup session data
request.session.pop('oauth2_github', None)
if is_popup:
# Return HTML with postMessage to opener
return HTMLResponse(content=f'''
<!DOCTYPE html>
<html>
<head><title>Authentication Complete</title></head>
<body>
<script>
if (window.opener) {{
window.opener.postMessage({{
type: 'oauth2_complete',
redirect_url: '{url_for(request, "/dashboard")}'
}}, '*');
}}
var msg = {{ type: 'oauth2_complete', redirect_url: '{url_for(request, "/dashboard")}' }};
try {{
var bc = new BroadcastChannel('oauth2_result');
bc.postMessage(msg);
bc.close();
}} catch(e) {{}}
try {{
if (window.opener) window.opener.postMessage(msg, '*');
}} catch(e) {{}}
window.close();
</script>
</body>
</html>
''')
# Redirect to dashboard for regular requests
return RedirectResponse(url=url_for(request, "/dashboard"), status_code=303)
except Exception as e:
logger.error(f"GitHub OAuth2 callback failed: {e}", exc_info=True)
return templates.TemplateResponse(
......@@ -7662,6 +7704,27 @@ async def api_save_encryption_key(request: Request):
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@app.post("/api/admin/settings/crypto-seeds-reset")
async def api_reset_crypto_seeds(request: Request):
"""Delete all crypto master seeds so they are regenerated on next restart"""
auth_check = require_api_admin(request)
if auth_check:
return auth_check
try:
db = DatabaseRegistry.get_config_database()
with db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM crypto_master_keys")
deleted = cursor.rowcount
conn.commit()
logger.warning(f"Admin reset crypto master seeds: {deleted} rows deleted")
return JSONResponse({"success": True, "deleted": deleted})
except Exception as e:
logger.error(f"Error resetting crypto seeds: {e}")
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
# Admin configuration API endpoints
@app.get("/api/admin/config/price-sources")
async def get_price_sources(request: Request):
......@@ -8404,37 +8467,23 @@ async def dashboard_wallet_topup(request: Request):
"confirmations": gw.get('confirmations', 3),
})
# Stripe: create checkout session
# Stripe: create checkout session (hosted redirect flow)
if method == 'stripe':
try:
payment_service = getattr(request.app.state, 'payment_service', None)
if payment_service and hasattr(payment_service, 'stripe_handler'):
from decimal import Decimal
intent = await payment_service.stripe_handler.create_topup_intent(
user_id, Decimal(str(amount))
)
if not intent.get('success'):
return JSONResponse({"error": intent.get('error', 'Stripe error')}, status_code=502)
return JSONResponse({"type": "stripe", "client_secret": intent['client_secret']})
# Fallback: redirect to Stripe-hosted checkout via publishable key
import stripe
stripe.api_key = gw.get('secret_key', '')
session = stripe.checkout.Session.create(
payment_method_types=['card'],
line_items=[{
'price_data': {
'currency': 'usd',
'product_data': {'name': 'Wallet Top-Up'},
'unit_amount': int(amount * 100),
},
'quantity': 1,
}],
mode='payment',
success_url=str(request.base_url) + 'dashboard/wallet?topup=success',
cancel_url=str(request.base_url) + 'dashboard/wallet?topup=cancelled',
metadata={'type': 'wallet_topup', 'user_id': str(user_id)},
if not payment_service or not hasattr(payment_service, 'stripe_handler'):
return JSONResponse({"error": "Stripe payment service unavailable"}, status_code=503)
from decimal import Decimal
base = str(request.base_url).rstrip('/')
result = await payment_service.stripe_handler.create_topup_checkout_session(
user_id,
Decimal(str(amount)),
success_url=f"{base}/dashboard/wallet?topup=success",
cancel_url=f"{base}/dashboard/wallet?topup=cancelled",
)
return JSONResponse({"type": "stripe", "checkout_url": session.url})
if not result.get('success'):
return JSONResponse({"error": result.get('error', 'Stripe error')}, status_code=502)
return JSONResponse({"type": "stripe", "checkout_url": result['checkout_url']})
except Exception as e:
logger.error(f"Stripe top-up error: {e}")
return JSONResponse({"error": "Stripe checkout failed. Please try again."}, status_code=502)
......@@ -9246,14 +9295,14 @@ async def dashboard_about(request: Request):
markdown_content,
extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists']
)
# Rewrite DOCUMENTATION.md links to /dashboard/docs
# Rewrite DOCUMENTATION.md links (relative or absolute) to /dashboard/docs
html_content = re.sub(
r'href="DOCUMENTATION\.md#([^"]*)"',
r'href="(?:https?://[^"]*?/)?DOCUMENTATION\.md#([^"]*)"',
r'href="/dashboard/docs#\1"',
html_content
)
html_content = re.sub(
r'href="DOCUMENTATION\.md"',
r'href="(?:https?://[^"]*?/)?DOCUMENTATION\.md"',
'href="/dashboard/docs"',
html_content
)
......
......@@ -523,7 +523,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% if request.session.user_id %}
<div class="account-menu">
<div class="account-trigger" onclick="toggleAccountMenu()">
<img src="{{ request.session.profile_pic if request.session.profile_pic else 'https://www.gravatar.com/avatar/' ~ (request.session.email|md5 if request.session.email else '') ~ '?s=48&d=identicon' }}" alt="User avatar">
<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">
<span>Account</span>
</div>
<div class="account-dropdown" id="account-dropdown">
......
......@@ -89,21 +89,34 @@
<div style="margin-bottom: 20px;">
<label style="display: block; margin-bottom: 10px; color: #e0e0e0; font-weight: 500;">
Set New Encryption Key (44 characters, base64)
Set New Encryption Key (44 characters, URL-safe base64)
</label>
<input type="text" id="encryptionKey" placeholder="Leave empty to auto-generate" style="width: 100%; padding: 10px; border: 1px solid #0f3460; border-radius: 5px; background: #1a1a2e; color: #e0e0e0; font-family: monospace;">
<input type="text" id="encryptionKey" placeholder="Click Generate to create a secure key" style="width: 100%; padding: 10px; border: 1px solid #0f3460; border-radius: 5px; background: #1a1a2e; color: #e0e0e0; font-family: monospace;">
<div style="margin-top: 10px; color: #888; font-size: 14px;">
<i class="fas fa-info-circle me-2"></i>Leave empty to auto-generate a secure key. Copy and save it securely - you cannot retrieve it later.
<i class="fas fa-info-circle me-2"></i>Generate a key, copy it to a safe place, then save it. You cannot retrieve it from the interface later.
</div>
</div>
<div style="display: flex; gap: 10px;">
<div id="encryptionKeyBackup" style="display:none; margin-bottom: 20px; padding: 15px; background: #2d1b00; border: 2px solid #f39c12; border-radius: 8px;">
<div style="color: #f39c12; font-weight: bold; margin-bottom: 8px;">
<i class="fas fa-exclamation-triangle me-2"></i>Save this key now — it will not be shown again after you leave this page
</div>
<div style="font-family: monospace; font-size: 14px; color: #e0e0e0; word-break: break-all;" id="encryptionKeyBackupValue"></div>
<button type="button" class="btn btn-sm mt-2" onclick="copyEncryptionKey()" style="background: #f39c12; color: #000;">
<i class="fas fa-copy me-1"></i>Copy to Clipboard
</button>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button type="button" class="btn" onclick="generateEncryptionKey()" style="background: #9b59b6; color: white;">
<i class="fas fa-random me-2"></i>Generate Random Key
<i class="fas fa-random me-2"></i>Generate New Key
</button>
<button type="button" class="btn" onclick="saveEncryptionKey()" style="background: #e74c3c; color: white;">
<i class="fas fa-save me-2"></i>Save Encryption Key
</button>
<button type="button" class="btn" onclick="resetCryptoSeeds()" style="background: #c0392b; color: white;">
<i class="fas fa-trash me-2"></i>Reset Crypto Seeds
</button>
</div>
</div>
......@@ -657,46 +670,60 @@ async function loadEncryptionKeyStatus() {
}
}
// Generate random encryption key
// Generate random encryption key (URL-safe base64, required by Fernet)
function generateEncryptionKey() {
// Generate 32 random bytes and encode as base64 (44 characters)
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const base64 = btoa(String.fromCharCode.apply(null, array));
// URL-safe base64: replace + with - and / with _
const base64 = btoa(String.fromCharCode.apply(null, array))
.replace(/\+/g, '-').replace(/\//g, '_');
document.getElementById('encryptionKey').value = base64;
showToast('Random encryption key generated. Save it securely!', 'warning');
document.getElementById('encryptionKeyBackup').style.display = 'block';
document.getElementById('encryptionKeyBackupValue').textContent = base64;
showToast('Encryption key generated. Copy it now and store it safely before saving!', 'warning');
}
// Copy key to clipboard then hide it
function copyEncryptionKey() {
const key = document.getElementById('encryptionKey').value.trim();
if (!key) return;
navigator.clipboard.writeText(key).then(() => {
showToast('Key copied to clipboard. Store it safely!', 'success');
document.getElementById('encryptionKeyBackup').style.display = 'none';
document.getElementById('encryptionKey').value = '';
});
}
// Save encryption key
async function saveEncryptionKey() {
const key = document.getElementById('encryptionKey').value.trim();
if (!key) {
showToast('Please enter or generate an encryption key', 'danger');
return;
}
if (key.length !== 44) {
showToast('Encryption key must be 44 characters (base64 encoded)', 'danger');
showToast('Encryption key must be 44 characters (URL-safe base64)', 'danger');
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") }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({encryption_key: key})
});
const result = await response.json();
if (response.ok && result.success) {
showToast('Encryption key saved successfully. Server restart required to take effect.', 'success');
document.getElementById('encryptionKey').value = '';
showToast('Encryption key saved. COPY THE KEY ABOVE before restarting the server!', 'success');
loadEncryptionKeyStatus();
// Do NOT clear the field — user needs to copy it for backup
} else {
showToast(result.error || 'Failed to save encryption key', 'danger');
}
......@@ -705,6 +732,30 @@ async function saveEncryptionKey() {
}
}
// Reset crypto wallet seeds (delete master keys so they are regenerated with the new encryption key)
async function resetCryptoSeeds() {
const ok = await showDangerConfirm(
'WARNING: This will delete all crypto master seeds. All existing derived addresses will be lost. Only do this after setting a new encryption key. Are you sure?',
'Reset Crypto Seeds'
);
if (!ok) return;
try {
const response = await fetch('{{ url_for(request, "/api/admin/settings/crypto-seeds-reset") }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
const result = await response.json();
if (response.ok && result.success) {
showToast('Crypto seeds deleted. Restart the server to generate new ones.', 'success');
} else {
showToast(result.error || 'Failed to reset crypto seeds', 'danger');
}
} catch (error) {
showToast('Error: ' + error.message, 'danger');
}
}
// Load system status
async function loadSystemStatus() {
try {
......
......@@ -101,79 +101,70 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<script>
// OAuth2 Popup Handling
function openOAuthPopup(url) {
// Add popup parameter to URL
const separator = url.includes('?') ? '&' : '?';
const popupUrl = url + separator + 'popup=1';
// Calculate popup position (center screen)
const width = 500;
const height = 650;
const width = 500, height = 650;
const left = (window.innerWidth / 2) - (width / 2);
const top = (window.innerHeight / 2) - (height / 2);
// Open popup window
const popup = window.open(
popupUrl,
'OAuth2 Authentication',
`width=${width},height=${height},top=${top},left=${left},resizable=no,scrollbars=yes,status=no`
);
// Listen for popup messages
let done = false;
function onSuccess() {
if (done) return;
done = true;
cleanup();
window.location.replace('/dashboard');
}
function onError(msg) {
if (done) return;
done = true;
cleanup();
showAlert(msg || 'Authentication failed', 'Authentication Error', '❌', 'danger');
}
// BroadcastChannel: works even when COOP severs window.opener
let bc = null;
try {
bc = new BroadcastChannel('oauth2_result');
bc.onmessage = function(e) {
if (e.data && e.data.type === 'oauth2_complete') onSuccess();
else if (e.data && e.data.type === 'oauth2_error') onError(e.data.error);
};
} catch(e) {}
// postMessage fallback (same-origin popups where opener is available)
const messageListener = function(event) {
console.log('Received OAuth message:', event.data, event.origin);
if (event.data && event.data.type === 'oauth2_complete') {
console.log('OAuth complete, redirecting to:', event.data.redirect_url || '/dashboard');
// Close popup
popup.close();
// Remove listener
window.removeEventListener('message', messageListener);
// Force redirect to dashboard - use relative path for reliability
window.location.replace('/dashboard');
} else if (event.data && event.data.type === 'oauth2_error') {
console.log('OAuth error:', event.data.error);
// Close popup
popup.close();
// Remove listener
window.removeEventListener('message', messageListener);
// Show error
showAlert(event.data.error || 'Authentication failed', 'Authentication Error', '❌', 'danger');
}
if (!event.data) return;
if (event.data.type === 'oauth2_complete') onSuccess();
else if (event.data.type === 'oauth2_error') onError(event.data.error);
};
window.addEventListener('message', messageListener);
// Fallback if popup is blocked or closed manually
// Popup-closed fallback: poll auth status via API (not just any 200)
const popupCheck = setInterval(function() {
if (popup.closed) {
clearInterval(popupCheck);
window.removeEventListener('message', messageListener);
// Check if authentication was successful by trying to load dashboard
// Use fetch with credentials: 'same-origin' to send cookies
fetch('/dashboard', {
method: 'GET',
credentials: 'same-origin',
redirect: 'follow'
}).then(response => {
// If we get a 200 OK (HTML) or redirect to dashboard, auth was successful
if (response.ok || response.redirected) {
console.log('Auth detected on popup close, redirecting to dashboard');
window.location.replace('/dashboard');
}
}).catch(error => {
console.log('Dashboard check failed:', error);
// Fallback: try direct redirect anyway for authenticated users
window.location.replace('/dashboard');
});
}
}, 500);
let closed = false;
try { closed = popup.closed; } catch(e) { /* COOP blocks access */ }
if (!closed) return;
clearInterval(popupCheck);
if (done) return;
fetch('/dashboard/api/auth-check', { credentials: 'same-origin' })
.then(r => r.json())
.then(data => { if (data.authenticated) onSuccess(); })
.catch(() => {});
}, 600);
function cleanup() {
clearInterval(popupCheck);
window.removeEventListener('message', messageListener);
if (bc) { bc.close(); bc = null; }
try { popup.close(); } catch(e) {}
}
}
</script>
</div>
......
......@@ -51,7 +51,7 @@
<div style="display: flex; align-items: flex-start; gap: 1.5rem; flex-wrap: wrap;">
<div style="position: relative; cursor: pointer; flex-shrink: 0;" id="avatar-wrap">
<img id="avatar-preview"
src="{{ user.profile_pic if user.profile_pic else 'https://www.gravatar.com/avatar/' ~ (session.email|md5 if session.email else '') ~ '?s=96&d=identicon' }}"
src="{{ '/dashboard/profile-pic' if user.profile_pic else 'https://www.gravatar.com/avatar/' ~ (session.email|md5 if session.email else '') ~ '?s=96&d=identicon' }}"
alt="Profile picture"
style="width: 96px; height: 96px; border-radius: 8px; object-fit: cover; display: block;">
<div id="avatar-overlay" style="position: absolute; inset: 0; background: rgba(0,0,0,0.45); border-radius: 8px; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.2s; pointer-events: none;">
......
......@@ -289,7 +289,7 @@
<!-- Hero -->
<div class="hero">
<div class="hero-avatar">
<img src="{{ session.profile_pic if session.profile_pic else 'https://www.gravatar.com/avatar/' ~ (session.email|md5 if session.email else '') ~ '?s=128&d=identicon' }}" alt="avatar">
<img src="{{ '/dashboard/profile-pic' if session.has_profile_pic else '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>
......
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