0.99.51

parent 8c425f1d
...@@ -96,12 +96,12 @@ curl -X POST http://localhost:17765/api/wallet/topup \ ...@@ -96,12 +96,12 @@ curl -X POST http://localhost:17765/api/wallet/topup \
## Documentation ## Documentation
For complete documentation, configuration guides, and API reference: For complete documentation, configuration guides, and API reference:
- **[📚 Full Documentation](DOCUMENTATION.md)** - Comprehensive user and developer guide - **[📚 Full Documentation](https://git.nexlab.net/nexlab/aisbf/src/master/DOCUMENTATION.md)** - Comprehensive user and developer guide
- **[🔧 Installation Guide](DOCUMENTATION.md#installation)** - Detailed setup instructions - **[🔧 Installation Guide](https://git.nexlab.net/nexlab/aisbf/src/master/DOCUMENTATION.md#installation)** - Detailed setup instructions
- **[⚙️ Configuration](DOCUMENTATION.md#configuration)** - All configuration options - **[⚙️ Configuration](https://git.nexlab.net/nexlab/aisbf/src/master/DOCUMENTATION.md#configuration)** - All configuration options
- **[💰 Wallet System](DOCUMENTATION.md#wallet-system)** - Complete wallet documentation - **[💰 Wallet System](https://git.nexlab.net/nexlab/aisbf/src/master/DOCUMENTATION.md#wallet-system)** - Complete wallet documentation
- **[🔌 API Reference](DOCUMENTATION.md#api-endpoints)** - Complete API documentation - **[🔌 API Reference](https://git.nexlab.net/nexlab/aisbf/src/master/DOCUMENTATION.md#api-endpoints)** - Complete API documentation
- **[🛠️ Development](DOCUMENTATION.md#development)** - Development and deployment guides - **[🛠️ Development](https://git.nexlab.net/nexlab/aisbf/src/master/DOCUMENTATION.md#development)** - Development and deployment guides
## 🚀 Support AISBF - Your Donations Matter! ## 🚀 Support AISBF - Your Donations Matter!
......
...@@ -200,6 +200,45 @@ class StripePaymentHandler: ...@@ -200,6 +200,45 @@ class StripePaymentHandler:
logger.error(f"Error creating Stripe top up intent: {e}") logger.error(f"Error creating Stripe top up intent: {e}")
return {'success': False, 'error': str(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): async def _handle_payment_succeeded(self, payment_intent: dict):
"""Handle successful Stripe payment — credits user wallet for top-up intents.""" """Handle successful Stripe payment — credits user wallet for top-up intents."""
logger.info(f"Payment succeeded: {payment_intent['id']}") logger.info(f"Payment succeeded: {payment_intent['id']}")
......
...@@ -1362,8 +1362,9 @@ async def startup_event(): ...@@ -1362,8 +1362,9 @@ async def startup_event():
if not encryption_key: if not encryption_key:
encryption_key = Fernet.generate_key().decode() encryption_key = Fernet.generate_key().decode()
encryption_key_source = 'temporary' encryption_key_source = 'generated'
logger.warning("No ENCRYPTION_KEY set in database or environment, generated temporary key") 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: else:
logger.info(f"Loaded ENCRYPTION_KEY from {encryption_key_source}") logger.info(f"Loaded ENCRYPTION_KEY from {encryption_key_source}")
...@@ -2462,6 +2463,42 @@ async def dashboard_analytics( ...@@ -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) @app.get("/dashboard/login", response_class=HTMLResponse)
async def dashboard_login_page(request: Request): async def dashboard_login_page(request: Request):
"""Show dashboard login page""" """Show dashboard login page"""
...@@ -2581,7 +2618,7 @@ async def dashboard_login(request: Request, username: str = Form(...), password: ...@@ -2581,7 +2618,7 @@ async def dashboard_login(request: Request, username: str = Form(...), password:
request.session['email'] = user.get('email') or '' request.session['email'] = user.get('email') or ''
request.session['role'] = user['role'] request.session['role'] = user['role']
request.session['user_id'] = user['id'] 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['remember_me'] = remember_me
request.session['email_verified'] = user['email_verified'] request.session['email_verified'] = user['email_verified']
if remember_me: if remember_me:
...@@ -3357,7 +3394,7 @@ async def dashboard_profile_pic_chunk( ...@@ -3357,7 +3394,7 @@ async def dashboard_profile_pic_chunk(
db = DatabaseRegistry.get_config_database() db = DatabaseRegistry.get_config_database()
db.update_user_profile(user_id, request.session.get('username', ''), None, None, data_url) 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}) return JSONResponse({"success": True, "complete": True})
...@@ -3767,7 +3804,7 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state ...@@ -3767,7 +3804,7 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state
request.session['email'] = existing_user.get('email', '') request.session['email'] = existing_user.get('email', '')
request.session['role'] = existing_user['role'] request.session['role'] = existing_user['role']
request.session['user_id'] = existing_user['id'] 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['email_verified'] = True # OAuth2 users have verified emails
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60 request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
...@@ -3827,12 +3864,15 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state ...@@ -3827,12 +3864,15 @@ async def oauth2_google_callback(request: Request, code: str = Query(...), state
<head><title>Authentication Complete</title></head> <head><title>Authentication Complete</title></head>
<body> <body>
<script> <script>
if (window.opener) {{ var msg = {{ type: 'oauth2_complete', redirect_url: '{url_for(request, "/dashboard")}' }};
window.opener.postMessage({{ try {{
type: 'oauth2_complete', var bc = new BroadcastChannel('oauth2_result');
redirect_url: '{url_for(request, "/dashboard")}' bc.postMessage(msg);
}}, '*'); bc.close();
}} }} catch(e) {{}}
try {{
if (window.opener) window.opener.postMessage(msg, '*');
}} catch(e) {{}}
window.close(); window.close();
</script> </script>
</body> </body>
...@@ -3959,7 +3999,7 @@ async def oauth2_github_callback(request: Request, code: str = Query(...), state ...@@ -3959,7 +3999,7 @@ async def oauth2_github_callback(request: Request, code: str = Query(...), state
request.session['email'] = existing_user.get('email', '') request.session['email'] = existing_user.get('email', '')
request.session['role'] = existing_user['role'] request.session['role'] = existing_user['role']
request.session['user_id'] = existing_user['id'] 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['email_verified'] = True # OAuth2 users have verified emails
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60 request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
...@@ -4004,19 +4044,21 @@ async def oauth2_github_callback(request: Request, code: str = Query(...), state ...@@ -4004,19 +4044,21 @@ async def oauth2_github_callback(request: Request, code: str = Query(...), state
# Cleanup session data # Cleanup session data
request.session.pop('oauth2_github', None) request.session.pop('oauth2_github', None)
if is_popup: if is_popup:
# Return HTML with postMessage to opener
return HTMLResponse(content=f''' return HTMLResponse(content=f'''
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head><title>Authentication Complete</title></head> <head><title>Authentication Complete</title></head>
<body> <body>
<script> <script>
if (window.opener) {{ var msg = {{ type: 'oauth2_complete', redirect_url: '{url_for(request, "/dashboard")}' }};
window.opener.postMessage({{ try {{
type: 'oauth2_complete', var bc = new BroadcastChannel('oauth2_result');
redirect_url: '{url_for(request, "/dashboard")}' bc.postMessage(msg);
}}, '*'); bc.close();
}} }} catch(e) {{}}
try {{
if (window.opener) window.opener.postMessage(msg, '*');
}} catch(e) {{}}
window.close(); window.close();
</script> </script>
</body> </body>
...@@ -7662,6 +7704,27 @@ async def api_save_encryption_key(request: Request): ...@@ -7662,6 +7704,27 @@ async def api_save_encryption_key(request: Request):
return JSONResponse({"success": False, "error": str(e)}, status_code=500) 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 # Admin configuration API endpoints
@app.get("/api/admin/config/price-sources") @app.get("/api/admin/config/price-sources")
async def get_price_sources(request: Request): async def get_price_sources(request: Request):
...@@ -8404,37 +8467,23 @@ async def dashboard_wallet_topup(request: Request): ...@@ -8404,37 +8467,23 @@ async def dashboard_wallet_topup(request: Request):
"confirmations": gw.get('confirmations', 3), "confirmations": gw.get('confirmations', 3),
}) })
# Stripe: create checkout session # Stripe: create checkout session (hosted redirect flow)
if method == 'stripe': if method == 'stripe':
try: try:
payment_service = getattr(request.app.state, 'payment_service', None) payment_service = getattr(request.app.state, 'payment_service', None)
if payment_service and hasattr(payment_service, 'stripe_handler'): 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 from decimal import Decimal
intent = await payment_service.stripe_handler.create_topup_intent( base = str(request.base_url).rstrip('/')
user_id, Decimal(str(amount)) result = await payment_service.stripe_handler.create_topup_checkout_session(
) user_id,
if not intent.get('success'): Decimal(str(amount)),
return JSONResponse({"error": intent.get('error', 'Stripe error')}, status_code=502) success_url=f"{base}/dashboard/wallet?topup=success",
return JSONResponse({"type": "stripe", "client_secret": intent['client_secret']}) cancel_url=f"{base}/dashboard/wallet?topup=cancelled",
# 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)},
) )
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: except Exception as e:
logger.error(f"Stripe top-up error: {e}") logger.error(f"Stripe top-up error: {e}")
return JSONResponse({"error": "Stripe checkout failed. Please try again."}, status_code=502) return JSONResponse({"error": "Stripe checkout failed. Please try again."}, status_code=502)
...@@ -9246,14 +9295,14 @@ async def dashboard_about(request: Request): ...@@ -9246,14 +9295,14 @@ async def dashboard_about(request: Request):
markdown_content, markdown_content,
extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists'] 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( html_content = re.sub(
r'href="DOCUMENTATION\.md#([^"]*)"', r'href="(?:https?://[^"]*?/)?DOCUMENTATION\.md#([^"]*)"',
r'href="/dashboard/docs#\1"', r'href="/dashboard/docs#\1"',
html_content html_content
) )
html_content = re.sub( html_content = re.sub(
r'href="DOCUMENTATION\.md"', r'href="(?:https?://[^"]*?/)?DOCUMENTATION\.md"',
'href="/dashboard/docs"', 'href="/dashboard/docs"',
html_content html_content
) )
......
...@@ -523,7 +523,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -523,7 +523,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% if request.session.user_id %} {% if request.session.user_id %}
<div class="account-menu"> <div class="account-menu">
<div class="account-trigger" onclick="toggleAccountMenu()"> <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> <span>Account</span>
</div> </div>
<div class="account-dropdown" id="account-dropdown"> <div class="account-dropdown" id="account-dropdown">
......
...@@ -89,21 +89,34 @@ ...@@ -89,21 +89,34 @@
<div style="margin-bottom: 20px;"> <div style="margin-bottom: 20px;">
<label style="display: block; margin-bottom: 10px; color: #e0e0e0; font-weight: 500;"> <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> </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;"> <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> </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;"> <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>
<button type="button" class="btn" onclick="saveEncryptionKey()" style="background: #e74c3c; color: white;"> <button type="button" class="btn" onclick="saveEncryptionKey()" style="background: #e74c3c; color: white;">
<i class="fas fa-save me-2"></i>Save Encryption Key <i class="fas fa-save me-2"></i>Save Encryption Key
</button> </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>
</div> </div>
...@@ -657,14 +670,28 @@ async function loadEncryptionKeyStatus() { ...@@ -657,14 +670,28 @@ async function loadEncryptionKeyStatus() {
} }
} }
// Generate random encryption key // Generate random encryption key (URL-safe base64, required by Fernet)
function generateEncryptionKey() { function generateEncryptionKey() {
// Generate 32 random bytes and encode as base64 (44 characters)
const array = new Uint8Array(32); const array = new Uint8Array(32);
crypto.getRandomValues(array); 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; 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 // Save encryption key
...@@ -677,7 +704,7 @@ async function saveEncryptionKey() { ...@@ -677,7 +704,7 @@ async function saveEncryptionKey() {
} }
if (key.length !== 44) { 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; return;
} }
...@@ -694,9 +721,9 @@ async function saveEncryptionKey() { ...@@ -694,9 +721,9 @@ async function saveEncryptionKey() {
const result = await response.json(); const result = await response.json();
if (response.ok && result.success) { if (response.ok && result.success) {
showToast('Encryption key saved successfully. Server restart required to take effect.', 'success'); showToast('Encryption key saved. COPY THE KEY ABOVE before restarting the server!', 'success');
document.getElementById('encryptionKey').value = '';
loadEncryptionKeyStatus(); loadEncryptionKeyStatus();
// Do NOT clear the field — user needs to copy it for backup
} else { } else {
showToast(result.error || 'Failed to save encryption key', 'danger'); showToast(result.error || 'Failed to save encryption key', 'danger');
} }
...@@ -705,6 +732,30 @@ async function saveEncryptionKey() { ...@@ -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 // Load system status
async function loadSystemStatus() { async function loadSystemStatus() {
try { try {
......
...@@ -101,79 +101,70 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -101,79 +101,70 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<script> <script>
// OAuth2 Popup Handling // OAuth2 Popup Handling
function openOAuthPopup(url) { function openOAuthPopup(url) {
// Add popup parameter to URL
const separator = url.includes('?') ? '&' : '?'; const separator = url.includes('?') ? '&' : '?';
const popupUrl = url + separator + 'popup=1'; const popupUrl = url + separator + 'popup=1';
// Calculate popup position (center screen) const width = 500, height = 650;
const width = 500;
const height = 650;
const left = (window.innerWidth / 2) - (width / 2); const left = (window.innerWidth / 2) - (width / 2);
const top = (window.innerHeight / 2) - (height / 2); const top = (window.innerHeight / 2) - (height / 2);
// Open popup window
const popup = window.open( const popup = window.open(
popupUrl, popupUrl,
'OAuth2 Authentication', 'OAuth2 Authentication',
`width=${width},height=${height},top=${top},left=${left},resizable=no,scrollbars=yes,status=no` `width=${width},height=${height},top=${top},left=${left},resizable=no,scrollbars=yes,status=no`
); );
// Listen for popup messages let done = false;
const messageListener = function(event) { function onSuccess() {
console.log('Received OAuth message:', event.data, event.origin); if (done) return;
done = true;
if (event.data && event.data.type === 'oauth2_complete') { cleanup();
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'); 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');
} }
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) {
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); 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() { const popupCheck = setInterval(function() {
if (popup.closed) { 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); clearInterval(popupCheck);
window.removeEventListener('message', messageListener); window.removeEventListener('message', messageListener);
if (bc) { bc.close(); bc = null; }
// Check if authentication was successful by trying to load dashboard try { popup.close(); } catch(e) {}
// 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);
} }
</script> </script>
</div> </div>
......
...@@ -51,7 +51,7 @@ ...@@ -51,7 +51,7 @@
<div style="display: flex; align-items: flex-start; gap: 1.5rem; flex-wrap: wrap;"> <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"> <div style="position: relative; cursor: pointer; flex-shrink: 0;" id="avatar-wrap">
<img id="avatar-preview" <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" alt="Profile picture"
style="width: 96px; height: 96px; border-radius: 8px; object-fit: cover; display: block;"> 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;"> <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 @@ ...@@ -289,7 +289,7 @@
<!-- Hero --> <!-- Hero -->
<div class="hero"> <div class="hero">
<div class="hero-avatar"> <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>
<div class="hero-text"> <div class="hero-text">
<h2>Welcome back, {{ display_name }}!</h2> <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