MongoDB authentication logs #3733
10 changed files+1412−415
Modifiedbackend/admin_routes.py+537−10View fileUnifiedSplit
@@ -4,9 +4,12 @@
44import os
55import logging
66from datetime import datetime
7from typing import Any, Dict, List, Optional, Tuple
78from fastapi import APIRouter, Request, Response, Form
89from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
910
11from config_env import env_presence_report, get_db_name, get_mongo_url_with_source
12
1013logger = logging.getLogger(__name__)
1114
1215router = APIRouter()
@@ -100,7 +103,7 @@ def admin_shell(req: Request):
100103 <header>
101104 <div>
102105 <div style="font-weight:700;">Edmund Panel</div>
103 <div class="meta">Admin + Cockpit + Booking Form Editor</div>
106 <div class="meta">Admin + Cockpit + Booking Form Editor + Recovery</div>
104107 </div>
105108 <div class="right"><a href="/admin/logout">Logout</a></div>
106109 </header>
@@ -109,6 +112,8 @@ def admin_shell(req: Request):
109112 <button class="tab active" data-url="/admin/bookings-view">Bookings</button>
110113 <button class="tab" data-url="/admin/cockpit">Cockpit</button>
111114 <button class="tab" data-url="/admin/booking-form">Booking Form</button>
115 <button class="tab" data-url="/admin/mongo-scan">Recovery</button>
116 <button class="tab" data-url="/admin/diagnostics">Diagnostics</button>
112117 <button class="tab" data-url="/admin/status">Status</button>
113118 </div>
114119
@@ -168,7 +173,7 @@ def admin_bookings_view(req: Request):
168173</head>
169174<body>
170175 <h2>Bookings</h2>
171 <div class="meta">Live from database. Auto-refreshes every 30s.</div>
176 <div class="meta">Live from database (Active/Deleted). Auto-refreshes every 30s.</div>
172177
173178 <div class="stats" id="stats"></div>
174179
@@ -180,6 +185,10 @@ def admin_bookings_view(req: Request):
180185 <option value="confirmed">Confirmed</option>
181186 <option value="cancelled">Cancelled</option>
182187 </select>
188 <select id="sourceFilter" onchange="loadBookings()" style="padding:8px 12px; border-radius:10px; border:1px solid #d1d5db; font-size:13px;">
189 <option value="active" selected>Active</option>
190 <option value="deleted">Deleted</option>
191 </select>
183192 <button onclick="loadBookings()">Refresh</button>
184193 </div>
185194
@@ -188,6 +197,19 @@ def admin_bookings_view(req: Request):
188197
189198<script>
190199let ALL = [];
200const QP = new URLSearchParams(window.location.search);
201const DB = QP.get('db'); // optional override for recovery
202const COLLECTION = QP.get('collection'); // optional override for recovery
203
204function apiUrl(){
205 const which = (document.getElementById('sourceFilter')?.value) || 'active';
206 const p = new URLSearchParams();
207 p.set('ts', String(Date.now()));
208 p.set('which', which);
209 if(DB) p.set('db', DB);
210 if(COLLECTION) p.set('collection', COLLECTION);
211 return '/api/admin/bookings-list?' + p.toString();
212}
191213
192214async function loadBookings(){
193215 const wrap = document.getElementById('table-wrap');
@@ -195,7 +217,7 @@ async function loadBookings(){
195217 err.style.display='none';
196218 wrap.innerHTML = '<div class="empty">Loading bookings...</div>';
197219 try {
198 const r = await fetch('/api/admin/bookings-list?ts='+Date.now());
220 const r = await fetch(apiUrl());
199221 if(!r.ok) throw new Error('HTTP '+r.status);
200222 const data = await r.json();
201223 ALL = data.items || [];
@@ -282,21 +304,101 @@ setInterval(loadBookings, 30000);
282304</html>""")
283305
284306@router.get("/api/admin/bookings-list")
285async def admin_bookings_list(req: Request):
307async def admin_bookings_list(
308 req: Request,
309 which: str = "active",
310 db: Optional[str] = None,
311 collection: Optional[str] = None,
312 limit: int = 500,
313):
286314 """Fetch bookings from MongoDB for the server-rendered admin panel."""
287315 if not _require(req):
288316 return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
289317 try:
290318 from motor.motor_asyncio import AsyncIOMotorClient
291 mongo_url = os.environ.get("MONGO_URL", "")
292 db_name = os.environ.get("DB_NAME", "hibiscus_shuttle")
319 mongo_url, mongo_src = get_mongo_url_with_source()
293320 if not mongo_url:
294 return JSONResponse({"ok": False, "error": "MONGO_URL not set", "items": []})
321 return JSONResponse(
322 {
323 "ok": False,
324 "error": "MONGO_URL not set",
325 "items": [],
326 "help": {
327 "message": "Set a MongoDB connection string in Render env vars.",
328 "accepted_env_vars": ["MONGO_URL", "MONGO_URI", "MONGODB_URI", "MONGODB_URL", "DATABASE_URL (mongodb only)"],
329 },
330 }
331 )
332
333 # Clamp to avoid huge responses
334 try:
335 limit = int(limit)
336 except Exception:
337 limit = 500
338 limit = max(1, min(limit, 2000))
339
295340 client = AsyncIOMotorClient(mongo_url)
296 db = client[db_name]
297 docs = await db.bookings.find({}, {"_id": 0}).sort("createdAt", -1).to_list(500)
341
342 # Choose DB: explicit query param > env DB_NAME > db in URI.
343 env_db_name = get_db_name()
344 override_db = (db or "").strip() or None
345 default_db_name = None
346 default_db = None
347 try:
348 default_db = client.get_default_database()
349 default_db_name = getattr(default_db, "name", None)
350 except Exception:
351 default_db = None
352 default_db_name = None
353
354 used_db_name = override_db or env_db_name or default_db_name
355 if not used_db_name:
356 client.close()
357 return JSONResponse(
358 {
359 "ok": False,
360 "error": "DB_NAME not set and MONGO_URL has no default database; set DB_NAME in Render env",
361 "items": [],
362 "source": {
363 "which": which,
364 "db": None,
365 "collection": None,
366 "env_db_name": env_db_name,
367 "default_db_name": default_db_name,
368 "mongo_url_source": mongo_src,
369 },
370 },
371 status_code=400,
372 )
373
374 used_db = client[used_db_name]
375
376 # Choose collection: explicit query param > 'which' mapping
377 which_norm = (which or "active").strip().lower()
378 if collection and collection.strip():
379 coll_name = collection.strip()
380 else:
381 coll_name = "deleted_bookings" if which_norm == "deleted" else "bookings"
382
383 sort_field = "deletedAt" if coll_name == "deleted_bookings" else "createdAt"
384 docs = await used_db[coll_name].find({}, {"_id": 0}).sort(sort_field, -1).to_list(limit)
298385 client.close()
299 return JSONResponse({"ok": True, "count": len(docs), "items": docs})
386 return JSONResponse(
387 {
388 "ok": True,
389 "count": len(docs),
390 "items": docs,
391 "source": {
392 "which": which_norm,
393 "db": used_db_name,
394 "collection": coll_name,
395 "limit": limit,
396 "env_db_name": env_db_name,
397 "default_db_name": default_db_name,
398 "mongo_url_source": mongo_src,
399 },
400 }
401 )
300402 except Exception as e:
301403 logger.error(f"admin_bookings_list error: {e}")
302404 return JSONResponse({"ok": False, "error": str(e), "items": []})
@@ -306,3 +408,428 @@ def admin_status(req: Request):
306408 if not _require(req):
307409 return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
308410 return JSONResponse({"ok": True, "utc": datetime.utcnow().isoformat() + "Z"})
411
412
413def _score_collection(name: str, keys: List[str]) -> Tuple[int, List[str]]:
414 """Heuristic to find likely bookings collections without returning PII."""
415 score = 0
416 reasons: List[str] = []
417 lname = (name or "").lower()
418 k = set(keys or [])
419
420 if "booking" in lname:
421 score += 5
422 reasons.append("name contains 'booking'")
423 if "deleted" in lname:
424 score += 2
425 reasons.append("name contains 'deleted'")
426 if "booking_ref" in k:
427 score += 6
428 reasons.append("has booking_ref field")
429 if "pickupAddress" in k and "dropoffAddress" in k:
430 score += 3
431 reasons.append("has pickup/dropoff fields")
432 if "date" in k and "time" in k:
433 score += 2
434 reasons.append("has date/time fields")
435 if "email" in k:
436 score += 1
437 reasons.append("has email field")
438 if "payment_status" in k:
439 score += 1
440 reasons.append("has payment_status field")
441
442 return score, reasons
443
444
445@router.get("/admin/mongo-scan", response_class=HTMLResponse)
446def admin_mongo_scan_page(req: Request):
447 if not _require(req):
448 return RedirectResponse(url="/admin/login", status_code=302)
449
450 return HTMLResponse("""<!doctype html>
451<html>
452<head>
453 <meta charset="utf-8" />
454 <meta name="viewport" content="width=device-width,initial-scale=1" />
455 <title>Recovery - Mongo Scan</title>
456 <style>
457 body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial; padding:18px; margin:0;}
458 h2{margin:0 0 6px 0;}
459 .meta{color:#6b7280; font-size:13px; margin-bottom:14px;}
460 button{padding:8px 14px; border-radius:10px; border:1px solid #e5e7eb; background:#111827; color:#fff; cursor:pointer; font-size:13px;}
461 .card{border:1px solid #e5e7eb; border-radius:14px; padding:14px; margin:12px 0;}
462 table{width:100%; border-collapse:collapse; font-size:13px; margin-top:10px;}
463 th{background:#f8fafc; text-align:left; padding:10px 8px; border-bottom:2px solid #e5e7eb; white-space:nowrap;}
464 td{padding:8px; border-bottom:1px solid #f1f5f9; vertical-align:top;}
465 tr:hover td{background:#f8fafc;}
466 code{background:#f3f4f6; padding:2px 6px; border-radius:6px;}
467 .err{color:#dc2626; font-size:13px; margin-top:10px;}
468 .small{color:#6b7280; font-size:12px;}
469 a{color:#111827;}
470 </style>
471</head>
472<body>
473 <h2>Recovery - Mongo Scan</h2>
474 <div class="meta">
475 If bookings look "missing", they are usually in a different database/collection (DB_NAME mismatch) or in <code>deleted_bookings</code>.
476 This scan only returns counts/field names (no customer data) and requires admin login.
477 </div>
478
479 <button onclick="runScan()">Run scan</button>
480 <div id="error" class="err" style="display:none;"></div>
481
482 <div id="summary" class="card"></div>
483 <div id="candidates" class="card"></div>
484 <div id="details" class="card"></div>
485
486<script>
487function esc(s){return String(s||'').replaceAll('&','&').replaceAll('<','<').replaceAll('>','>');}
488function linkToBookings(db, collection){
489 const u = '/admin/bookings-view?db='+encodeURIComponent(db)+'&collection='+encodeURIComponent(collection);
490 return '<a href=\"'+u+'\">View</a>';
491}
492
493async function runScan(){
494 const err = document.getElementById('error');
495 err.style.display='none';
496 document.getElementById('summary').innerHTML = '<div class=\"small\">Scanning...</div>';
497 document.getElementById('candidates').innerHTML = '';
498 document.getElementById('details').innerHTML = '';
499 try{
500 const r = await fetch('/api/admin/mongo-scan?ts='+Date.now());
501 const data = await r.json();
502 if(!r.ok || !data.ok){
503 throw new Error(data.error || ('HTTP '+r.status));
504 }
505
506 const src = data.source || {};
507 document.getElementById('summary').innerHTML = `
508 <div><b>Connected</b></div>
509 <div class=\"small\">Used DB: <code>${esc(src.used_db_name||'n/a')}</code></div>
510 <div class=\"small\">Env DB_NAME: <code>${esc(src.env_db_name||'')}</code> | URI default DB: <code>${esc(src.default_db_name||'')}</code></div>
511 <div class=\"small\">Tip: If the right bookings are in a different DB, update Render env <code>DB_NAME</code> to match.</div>
512 `;
513
514 const cand = (data.booking_candidates || []);
515 if(!cand.length){
516 document.getElementById('candidates').innerHTML = '<b>Booking candidates</b><div class=\"small\">No obvious bookings collections found in scanned DBs.</div>';
517 } else {
518 let html = '<b>Booking candidates</b><table><thead><tr><th>DB</th><th>Collection</th><th>Count</th><th>Why</th><th></th></tr></thead><tbody>';
519 for(const c of cand){
520 html += '<tr>';
521 html += '<td><code>'+esc(c.db)+'</code></td>';
522 html += '<td><code>'+esc(c.collection)+'</code></td>';
523 html += '<td>'+esc(c.count)+'</td>';
524 html += '<td class=\"small\">'+esc((c.reasons||[]).join('; '))+'</td>';
525 html += '<td>'+linkToBookings(c.db, c.collection)+'</td>';
526 html += '</tr>';
527 }
528 html += '</tbody></table>';
529 document.getElementById('candidates').innerHTML = html;
530 }
531
532 // Details table (first scanned db only, to keep it readable)
533 const dbs = data.databases || [];
534 if(dbs.length){
535 const first = dbs[0];
536 let html = '<b>Collections in '+esc(first.name)+'</b>';
537 html += '<table><thead><tr><th>Collection</th><th>Count</th><th>Fields (sample)</th></tr></thead><tbody>';
538 for(const c of (first.collections||[])){
539 html += '<tr>';
540 html += '<td><code>'+esc(c.name)+'</code></td>';
541 html += '<td>'+esc(c.count)+'</td>';
542 html += '<td class=\"small\">'+esc((c.sample_keys||[]).join(', '))+'</td>';
543 html += '</tr>';
544 }
545 html += '</tbody></table>';
546 if((data.errors||[]).length){
547 html += '<div class=\"err\" style=\"margin-top:10px;\">'+esc((data.errors||[]).join(' | '))+'</div>';
548 }
549 document.getElementById('details').innerHTML = html;
550 }
551
552 }catch(e){
553 err.textContent = 'Scan failed: ' + String(e);
554 err.style.display='block';
555 document.getElementById('summary').innerHTML = '';
556 }
557}
558</script>
559</body>
560</html>""")
561
562
563
564async def admin_mongo_scan(req: Request):
565 if not _require(req):
566 return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
567 try:
568 from motor.motor_asyncio import AsyncIOMotorClient
569
570 mongo_url, mongo_src = get_mongo_url_with_source()
571 if not mongo_url:
572 return JSONResponse(
573 {
574 "ok": False,
575 "error": "MONGO_URL not set",
576 "help": {
577 "message": "This backend cannot scan for bookings until a MongoDB connection string is configured.",
578 "accepted_env_vars": ["MONGO_URL", "MONGO_URI", "MONGODB_URI", "MONGODB_URL", "DATABASE_URL (mongodb only)"],
579 },
580 },
581 status_code=400,
582 )
583
584 client = AsyncIOMotorClient(mongo_url)
585 errors: List[str] = []
586
587 env_db_name = get_db_name()
588 default_db_name = None
589 try:
590 default_db_name = client.get_default_database().name
591 except Exception:
592 default_db_name = None
593
594 used_db_name = env_db_name or default_db_name
595
596 # Try to enumerate databases (may require extra privileges).
597 db_names: List[str] = []
598 try:
599 db_names = await client.list_database_names()
600 except Exception as e:
601 errors.append(f"list_database_names not permitted: {e}")
602
603 # Always scan the "used" DB first if we know it.
604 ordered_dbs: List[str] = []
605 if used_db_name:
606 ordered_dbs.append(used_db_name)
607 for n in db_names:
608 if n not in ordered_dbs:
609 ordered_dbs.append(n)
610
611 if not ordered_dbs:
612 client.close()
613 return JSONResponse(
614 {
615 "ok": False,
616 "error": "Could not determine any database to scan (set DB_NAME, or ensure URI includes a database)",
617 },
618 status_code=400,
619 )
620
621 # Keep scan lightweight
622 ordered_dbs = ordered_dbs[:10]
623
624 databases_out: List[Dict[str, Any]] = []
625 candidates: List[Dict[str, Any]] = []
626
627 for db_name in ordered_dbs:
628 db = client[db_name]
629 try:
630 col_names = await db.list_collection_names()
631 except Exception as e:
632 errors.append(f"list_collection_names failed for db {db_name}: {e}")
633 continue
634
635 cols_out: List[Dict[str, Any]] = []
636 for col_name in col_names[:60]:
637 coll = db[col_name]
638 count: Optional[int] = None
639 sample_keys: List[str] = []
640 try:
641 count = await coll.estimated_document_count()
642 except Exception:
643 count = None
644 try:
645 doc = await coll.find_one({}, {"_id": 0})
646 if isinstance(doc, dict):
647 sample_keys = sorted(list(doc.keys()))
648 except Exception:
649 sample_keys = []
650
651 score, reasons = _score_collection(col_name, sample_keys)
652 cols_out.append(
653 {
654 "name": col_name,
655 "count": count,
656 "score": score,
657 "reasons": reasons,
658 "sample_keys": sample_keys,
659 }
660 )
661
662 if score >= 6 or "booking" in (col_name or "").lower():
663 candidates.append(
664 {
665 "db": db_name,
666 "collection": col_name,
667 "count": count,
668 "score": score,
669 "reasons": reasons,
670 }
671 )
672
673 # Sort collections within a DB by score then count
674 cols_out.sort(key=lambda x: (x.get("score", 0), x.get("count") or 0), reverse=True)
675 databases_out.append({"name": db_name, "collections": cols_out})
676
677 client.close()
678
679 # Best candidates first
680 candidates.sort(key=lambda x: (x.get("score", 0), x.get("count") or 0), reverse=True)
681
682 return JSONResponse(
683 {
684 "ok": True,
685 "source": {
686 "env_db_name": env_db_name,
687 "default_db_name": default_db_name,
688 "used_db_name": used_db_name,
689 "mongo_url_source": mongo_src,
690 },
691 "databases": databases_out,
692 "booking_candidates": candidates[:25],
693 "errors": errors,
694 }
695 )
696 except Exception as e:
697 logger.error(f"admin_mongo_scan error: {e}")
698 return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
699
700
701
702def admin_diagnostics_page(req: Request):
703 if not _require(req):
704 return RedirectResponse(url="/admin/login", status_code=302)
705
706 return HTMLResponse("""<!doctype html>
707<html>
708<head>
709 <meta charset="utf-8" />
710 <meta name="viewport" content="width=device-width,initial-scale=1" />
711 <title>Diagnostics</title>
712 <style>
713 body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial; padding:18px; margin:0;}
714 h2{margin:0 0 6px 0;}
715 .meta{color:#6b7280; font-size:13px; margin-bottom:14px;}
716 button{padding:8px 14px; border-radius:10px; border:1px solid #e5e7eb; background:#111827; color:#fff; cursor:pointer; font-size:13px;}
717 table{width:100%; border-collapse:collapse; font-size:13px; margin-top:10px;}
718 th{background:#f8fafc; text-align:left; padding:10px 8px; border-bottom:2px solid #e5e7eb; white-space:nowrap;}
719 td{padding:8px; border-bottom:1px solid #f1f5f9; vertical-align:top;}
720 tr:hover td{background:#f8fafc;}
721 code{background:#f3f4f6; padding:2px 6px; border-radius:6px;}
722 .ok{color:#065f46;}
723 .bad{color:#991b1b;}
724 .small{color:#6b7280; font-size:12px;}
725 .err{color:#dc2626; font-size:13px; margin-top:10px;}
726 </style>
727</head>
728<body>
729 <h2>Diagnostics</h2>
730 <div class="meta">Shows whether critical env vars are configured (never shows secret values).</div>
731 <button onclick="loadDiag()">Refresh</button>
732 <div id="error" class="err" style="display:none;"></div>
733 <div id="out"></div>
734<script>
735function esc(s){return String(s||'').replaceAll('&','&').replaceAll('<','<').replaceAll('>','>');}
736function badge(ok){return ok ? '<span class=\"ok\">SET</span>' : '<span class=\"bad\">MISSING</span>';}
737async function loadDiag(){
738 const err = document.getElementById('error');
739 const out = document.getElementById('out');
740 err.style.display='none';
741 out.innerHTML = '<div class=\"small\">Loading...</div>';
742 try{
743 const r = await fetch('/api/admin/diagnostics?ts='+Date.now());
744 const data = await r.json();
745 if(!r.ok || !data.ok) throw new Error(data.error || ('HTTP '+r.status));
746 const cfg = data.config || {};
747 let html = '<h3>Environment</h3>';
748 html += '<table><thead><tr><th>Key</th><th>Status</th><th>Notes</th></tr></thead><tbody>';
749 for(const k of Object.keys(cfg)){
750 const item = cfg[k] || {};
751 let notes = '';
752 if(k==='MONGO_URL'){
753 notes = 'source=' + esc(item.source||'') + '; candidates=' + esc((item.candidates||[]).join(', '));
754 }
755 html += '<tr><td><code>'+esc(k)+'</code></td><td>'+badge(!!item.set)+'</td><td class=\"small\">'+notes+'</td></tr>';
756 }
757 html += '</tbody></table>';
758
759 const db = data.db || {};
760 html += '<h3 style=\"margin-top:18px;\">Database</h3>';
761 if(db.ok){
762 html += '<div class=\"small\">Connected to <code>'+esc(db.db_name||'')+'</code></div>';
763 html += '<table><thead><tr><th>Collection</th><th>Count</th></tr></thead><tbody>';
764 for(const c of (db.collections||[])){
765 html += '<tr><td><code>'+esc(c.name)+'</code></td><td>'+esc(c.count)+'</td></tr>';
766 }
767 html += '</tbody></table>';
768 } else {
769 html += '<div class=\"bad\">Not connected</div><div class=\"small\">'+esc(db.error||'')+'</div>';
770 }
771
772 out.innerHTML = html;
773 }catch(e){
774 err.textContent = 'Diagnostics failed: ' + String(e);
775 err.style.display = 'block';
776 out.innerHTML = '';
777 }
778}
779loadDiag();
780</script>
781</body>
782</html>""")
783
784
785
786async def admin_diagnostics(req: Request):
787 if not _require(req):
788 return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
789
790 config = env_presence_report()
791 db_report: Dict[str, Any] = {"ok": False}
792
793 mongo_url, mongo_src = get_mongo_url_with_source()
794 if mongo_url:
795 try:
796 from motor.motor_asyncio import AsyncIOMotorClient
797
798 client = AsyncIOMotorClient(mongo_url)
799 env_db_name = get_db_name()
800 default_db_name = None
801 try:
802 default_db_name = client.get_default_database().name
803 except Exception:
804 default_db_name = None
805
806 used_db_name = env_db_name or default_db_name
807 if not used_db_name:
808 db_report = {
809 "ok": False,
810 "error": "DB_NAME not set and URI has no default database",
811 "mongo_url_source": mongo_src,
812 "env_db_name": env_db_name,
813 "default_db_name": default_db_name,
814 }
815 else:
816 db = client[used_db_name]
817 collections = []
818 for name in ("bookings", "deleted_bookings", "admins", "admin_sessions"):
819 try:
820 count = await db[name].estimated_document_count()
821 except Exception:
822 count = None
823 collections.append({"name": name, "count": count})
824 db_report = {"ok": True, "db_name": used_db_name, "collections": collections}
825 client.close()
826 except Exception as e:
827 db_report = {"ok": False, "error": str(e), "mongo_url_source": mongo_src}
828 else:
829 db_report = {
830 "ok": False,
831 "error": "MONGO_URL not set",
832 "accepted_env_vars": ["MONGO_URL", "MONGO_URI", "MONGODB_URI", "MONGODB_URL", "DATABASE_URL (mongodb only)"],
833 }
834
835 return JSONResponse({"ok": True, "utc": datetime.utcnow().isoformat() + "Z", "config": config, "db": db_report})
Modifiedbackend/agent_routes.py+350−72View fileUnifiedSplit
@@ -1,71 +1,201 @@
1# ===== HIBISCUS_COCKPIT_002_20260201_190341 =====
1"""
2backend/agent_routes.py
3
4Admin-only "Cockpit" + 9 specialist agent prompts.
5
6This is intentionally SAFE:
7- Agents can generate plans, content, and patch suggestions.
8- Agents do not execute code changes or shell commands on the server.
9"""
10
11import asyncio
12import os
13import time
14import uuid
15from pathlib import Path
16from typing import Any, Dict, List, Optional
17
218from fastapi import APIRouter, Request
3# cockpit_router is mounted separately by server.py
4from fastapi.responses import HTMLResponse, JSONResponse
19from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
520from pydantic import BaseModel
6from typing import Any, Dict, Optional
7import time, uuid, os
21
22from agent_runtime import run_agent_openai
23from config_env import env_presence_report
824
925router = APIRouter()
10_JOBS = [] # newest first
26
27AGENTS_DIR = Path(__file__).resolve().parent / "agents"
28
29ADMIN_COOKIE = "d8_admin"
30ADMIN_API_KEY = os.environ.get("ADMIN_API_KEY", "").strip()
31
32_JOBS: List[Dict[str, Any]] = [] # newest first (in-memory, per instance)
33
34
35def _now() -> int:
36 return int(time.time())
37
38
39def _is_authed(req: Request) -> bool:
40 if ADMIN_API_KEY == "":
41 return False
42 h = (req.headers.get("X-Admin-Key") or "").strip()
43 if h and h == ADMIN_API_KEY:
44 return True
45 c = (req.cookies.get(ADMIN_COOKIE) or "").strip()
46 return c == ADMIN_API_KEY
47
48
49def _agent_files() -> List[Path]:
50 if not AGENTS_DIR.exists():
51 return []
52 files = [p for p in AGENTS_DIR.iterdir() if p.is_file() and p.suffix.lower() == ".md"]
53 files.sort(key=lambda p: p.name)
54 return files
55
56
57def _agent_list() -> List[Dict[str, str]]:
58 out: List[Dict[str, str]] = []
59 for p in _agent_files():
60 agent_id = p.stem
61 title = agent_id
62 try:
63 first = (p.read_text(encoding="utf-8").splitlines() or [""])[0].strip()
64 if first.startswith("#"):
65 title = first.lstrip("#").strip()
66 except Exception:
67 pass
68 out.append({"id": agent_id, "title": title})
69 return out
70
71
72def _add_job(kind: str, payload: Dict[str, Any]) -> Dict[str, Any]:
73 job = {"id": str(uuid.uuid4()), "ts": _now(), "kind": kind, "payload": payload, "status": "running"}
74 _JOBS.insert(0, job)
75 del _JOBS[80:]
76 return job
77
78
79async def _run_agent(agent_id: str, message: str, context: Dict[str, Any]) -> Dict[str, Any]:
80 # run_agent_openai is synchronous; keep the event loop responsive.
81 return await asyncio.to_thread(run_agent_openai, agent_id, message, context)
82
1183
1284class CockpitRun(BaseModel):
1385 action: str
1486 prompt: Optional[str] = ""
1587 meta: Optional[Dict[str, Any]] = None
1688
17def _now(): return int(time.time())
89
90class AgentRun(BaseModel):
91 agentId: str
92 message: str
93 context: Optional[Dict[str, Any]] = None
94
1895
1996
20def cockpit_stamp():
21 return {"ok": True, "stamp": "HIBISCUS_COCKPIT_002_20260201_190341", "ts": _now()}
97def cockpit_stamp(req: Request):
98 if not _is_authed(req):
99 return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
100 return {"ok": True, "stamp": "HIBISCUS_COCKPIT_003", "ts": _now(), "agentsCount": len(_agent_files())}
101
22102
23103
24def agent_cockpit():
25 html = r"""
26<!doctype html><html><head><meta charset="utf-8"/>
27<meta name="viewport" content="width=device-width,initial-scale=1"/>
28<title>Hibiscus Cockpit</title>
29<style>
30body{margin:0;font-family:system-ui;background:#070A12;color:#fff;display:flex;justify-content:center;padding:24px}
31.card{width:min(980px,96vw);background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
32border-radius:22px;padding:18px 18px 14px}
33h1{margin:10px 0 14px;text-align:center;font-size:40px}
34.bar{display:flex;gap:10px;align-items:center;background:rgba(0,0,0,.28);border:1px solid rgba(255,255,255,.10);
35border-radius:999px;padding:12px 12px}
36input{flex:1;background:transparent;border:0;outline:none;color:#fff;font-size:16px;padding:6px 8px}
37button{border:0;border-radius:999px;padding:10px 18px;font-weight:700;color:#fff;cursor:pointer;
38background:linear-gradient(180deg,#60A5FA,#3B82F6)}
39.grid{display:grid;grid-template-columns:1.1fr .9fr;gap:14px;margin-top:14px}
40.box{background:rgba(0,0,0,.22);border:1px solid rgba(255,255,255,.10);border-radius:18px;padding:12px}
41.row{display:flex;justify-content:space-between;gap:10px;padding:10px;border-radius:14px}
42.row:hover{background:rgba(255,255,255,.04)}
43.small{color:rgba(255,255,255,.55);font-size:12px}
44.jobs{max-height:240px;overflow:auto;display:flex;flex-direction:column;gap:8px}
45.job{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.10);border-radius:14px;padding:10px}
46</style></head>
104def agent_cockpit(req: Request):
105 if not _is_authed(req):
106 return RedirectResponse(url="/admin/login", status_code=302)
107
108 # Single-page admin tool. Uses the existing /admin/login cookie (d8_admin).
109 html = r"""<!doctype html>
110<html>
111<head>
112 <meta charset="utf-8"/>
113 <meta name="viewport" content="width=device-width,initial-scale=1"/>
114 <title>Agent Cockpit</title>
115 <style>
116 body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;background:#070A12;color:#fff;display:flex;justify-content:center;padding:18px}
117 .card{width:min(1040px,96vw);background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);border-radius:18px;padding:16px}
118 h1{margin:6px 0 10px;font-size:28px}
119 .small{color:rgba(255,255,255,.60);font-size:12px}
120 .grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:12px}
121 .box{background:rgba(0,0,0,.22);border:1px solid rgba(255,255,255,.10);border-radius:14px;padding:12px}
122 label{display:block;font-size:12px;color:rgba(255,255,255,.65);margin-bottom:6px}
123 select,textarea,input{width:100%;box-sizing:border-box;background:#0c1020;color:#fff;border:1px solid rgba(255,255,255,.14);border-radius:12px;padding:10px}
124 textarea{min-height:140px;resize:vertical}
125 button{border:0;border-radius:12px;padding:10px 12px;font-weight:700;color:#fff;cursor:pointer;background:#2563eb}
126 button.secondary{background:#111827;border:1px solid rgba(255,255,255,.12)}
127 button:disabled{opacity:.6;cursor:not-allowed}
128 .row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
129 pre{white-space:pre-wrap;word-break:break-word;background:#0c1020;border:1px solid rgba(255,255,255,.10);padding:12px;border-radius:12px;max-height:420px;overflow:auto}
130 .jobs{max-height:240px;overflow:auto;display:flex;flex-direction:column;gap:8px}
131 .job{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.10);border-radius:12px;padding:10px}
132 .pill{display:inline-block;padding:2px 8px;border-radius:999px;background:rgba(255,255,255,.10);font-size:12px}
133 a{color:#93c5fd}
134 </style>
135</head>
47136<body>
48<div class="card" data-stamp="HIBISCUS_COCKPIT_002_20260201_190341">
49 <div class="small">api.hibiscustoairport.co.nz • HIBISCUS_COCKPIT_002_20260201_190341</div>
50 <h1>What would you like to run?</h1>
51 <div class="bar">
52 <input id="p" placeholder="repair failing CI, build patch, run SEO..."/>
53 <button id="go">GENERATE</button>
54 </div>
55 <div class="grid">
56 <div class="box">
57 <div class="row"><div><b>Repair Pack</b><div class="small">9 agents</div></div><button onclick="run('repair_pack')">Run</button></div>
58 <div class="row"><div><b>Patch Builder</b><div class="small">unified diff</div></div><button onclick="run('patch_builder')">Run</button></div>
59 <div class="row"><div><b>PR Dispatch</b><div class="small">Agent PR workflow</div></div><button onclick="run('dispatch_pr')">Run</button></div>
60 <div class="row"><div><b>SEO / Website</b><div class="small">only if endpoint exists</div></div><button onclick="run('seo_run')">Run</button></div>
137 <div class="card">
138 <div class="small">Admin-only. Uses /admin/login cookie. No secrets displayed.</div>
139 <h1>Agent Cockpit</h1>
140
141 <div class="row small" style="margin-bottom:10px;">
142 <span class="pill" id="agentsCount">agents: ...</span>
143 <span class="pill" id="envStatus">env: ...</span>
144 <a href="/admin/diagnostics" target="_blank" rel="noreferrer">Open Diagnostics</a>
145 <a href="/admin/mongo-scan" target="_blank" rel="noreferrer">Open Recovery Scan</a>
61146 </div>
62 <div class="box">
63 <b>Activity</b>
64 <div class="jobs" id="jobs"></div>
147
148 <div class="grid">
149 <div class="box">
150 <div class="row">
151 <div style="flex:1;min-width:220px;">
152 <label>Agent (01-09)</label>
153 <select id="agent"></select>
154 </div>
155 <div class="row" style="align-items:flex-end;">
156 <button class="secondary" id="presetRepair">Preset: Repair/Recover</button>
157 <button class="secondary" id="presetSEO">Preset: Eco SEO</button>
158 <button class="secondary" id="presetTikTok">Preset: TikTok Pack</button>
159 </div>
160 </div>
161
162 <div style="margin-top:12px;">
163 <label>Instruction</label>
164 <textarea id="msg" placeholder="Example: Find why bookings are missing. Then propose the minimum safe fix."></textarea>
165 </div>
166
167 <div class="row" style="margin-top:12px;">
168 <button id="run">Run Agent</button>
169 <button class="secondary" id="check">Quick Checks</button>
170 </div>
171
172 <div class="small" style="margin-top:10px;">
173 If you want real AI output, set <b>OPENAI_API_KEY</b> on Render. Otherwise the server returns a safe stub.
174 </div>
175 </div>
176
177 <div class="box">
178 <b>Activity</b>
179 <div class="jobs" id="jobs"></div>
180 </div>
181 </div>
182
183 <div class="box" style="margin-top:14px;">
184 <b>Output</b>
185 <pre id="out">Ready.</pre>
65186 </div>
66187 </div>
67</div>
188
68189<script>
190const agentEl = document.getElementById('agent');
191const msgEl = document.getElementById('msg');
192const outEl = document.getElementById('out');
193const jobsEl = document.getElementById('jobs');
194const agentsCountEl = document.getElementById('agentsCount');
195const envStatusEl = document.getElementById('envStatus');
196
197function esc(s){ return String(s||'').replaceAll('&','&').replaceAll('<','<').replaceAll('>','>'); }
198
69199async function api(path, opts){
70200 const u = path + (path.includes('?')?'&':'?') + 'ts=' + Math.floor(Date.now()/1000);
71201 const r = await fetch(u, opts||{});
@@ -73,42 +203,190 @@ async function api(path, opts){
73203 let j=null; try{ j=JSON.parse(t);}catch{}
74204 return {ok:r.ok, status:r.status, json:j, text:t};
75205}
76function render(list){
77 const root=document.getElementById('jobs'); root.innerHTML='';
78 if(!list || !list.length){ root.innerHTML='<div class="job"><b>No jobs yet</b><div class="small">Run something.</div></div>'; return; }
206
207function renderJobs(list){
208 jobsEl.innerHTML='';
209 if(!list || !list.length){
210 jobsEl.innerHTML='<div class="job"><b>No jobs yet</b><div class="small">Run something.</div></div>';
211 return;
212 }
79213 for(const x of list){
80214 const d=document.createElement('div'); d.className='job';
81 d.innerHTML = '<b>'+x.kind+'</b> • '+x.status+'<div class="small">'+JSON.stringify(x.payload).slice(0,180)+'</div>';
82 root.appendChild(d);
215 const when = x.ts ? new Date(x.ts*1000).toLocaleString() : '';
216 d.innerHTML = '<b>'+esc(x.kind)+'</b> <span class="small">('+esc(x.status)+') '+esc(when)+'</span>'
217 + '<div class="small">'+esc(JSON.stringify(x.payload||{}).slice(0,220))+'</div>';
218 jobsEl.appendChild(d);
83219 }
84220}
221
85222async function refresh(){
86223 const s = await api('/api/cockpit/state');
87 if(s.ok && s.json) render(s.json.jobs||[]);
224 if(s.ok && s.json){
225 renderJobs(s.json.jobs||[]);
226 const agents = s.json.agents || {};
227 const n = (agents.list||[]).length;
228 agentsCountEl.textContent = 'agents: ' + n;
229 envStatusEl.textContent = 'mongo: ' + ((s.json.env && s.json.env.MONGO_URL && s.json.env.MONGO_URL.set) ? 'SET' : 'MISSING');
230 }
231}
232
233async function loadAgents(){
234 const r = await api('/api/agents/list');
235 if(!r.ok || !r.json || !r.json.ok){ throw new Error(r.json?.error || ('HTTP '+r.status)); }
236 agentEl.innerHTML='';
237 for(const a of (r.json.items||[])){
238 const opt = document.createElement('option');
239 opt.value = a.id;
240 opt.textContent = a.title || a.id;
241 agentEl.appendChild(opt);
242 }
88243}
89async function run(action){
90 const prompt = (document.getElementById('p').value||'');
91 const payload = {action, prompt, meta:{from:'cockpit'}};
92 const r = await api('/api/cockpit/run',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload)});
244
245async function runAgent(){
246 const agentId = (agentEl.value||'').trim();
247 const message = (msgEl.value||'').trim();
248 if(!agentId){ outEl.textContent='Choose an agent.'; return; }
249 if(!message){ outEl.textContent='Type an instruction first.'; return; }
250 outEl.textContent='Running '+agentId+'...';
251 const payload = {agentId, message, context: {from:'agent-cockpit', url: location.href}};
252 const r = await api('/api/agents/run', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(payload)});
93253 await refresh();
94 if(!r.ok) alert('Run failed: '+r.status+'\\n'+(r.text||''));
254 if(!r.ok){ outEl.textContent='HTTP '+r.status+'\\n\\n'+(r.text||''); return; }
255 outEl.textContent = JSON.stringify(r.json, null, 2);
256}
257
258async function quickChecks(){
259 const paths = ['/debug/stamp','/debug/routes','/admin/status','/api/admin/diagnostics'];
260 outEl.textContent = 'Running quick checks...';
261 let all='';
262 for(const p of paths){
263 const r = await api(p);
264 all += '=== '+p+' (HTTP '+r.status+') ===\\n';
265 all += (r.text||'') + '\\n\\n';
266 }
267 outEl.textContent = all;
95268}
96document.getElementById('go').onclick=()=>run('repair_pack');
97refresh(); setInterval(refresh, 5000);
269
270document.getElementById('run').onclick = ()=>runAgent().catch(e=>outEl.textContent=String(e));
271document.getElementById('check').onclick = ()=>quickChecks().catch(e=>outEl.textContent=String(e));
272
273document.getElementById('presetRepair').onclick = ()=>{
274 agentEl.value = '06_ops_reliability';
275 msgEl.value = 'Diagnose why bookings are missing and propose the minimum safe fix. Include exact Render env vars needed (MONGO_URL + DB_NAME), and a checklist.';
276};
277document.getElementById('presetSEO').onclick = ()=>{
278 agentEl.value = '01_dispatcher';
279 msgEl.value = 'Create an aggressive local SEO campaign focused on eco-friendly airport transfers for Hibiscus to Airport. Deliver: 10 landing page topics, 30 GBP post ideas, 20 FAQ snippets, and a 7-day execution checklist.';
280};
281document.getElementById('presetTikTok').onclick = ()=>{
282 agentEl.value = '01_dispatcher';
283 msgEl.value = 'Generate a TikTok content pack for Hibiscus to Airport (eco angle). Deliver: 20 hooks, 10 full 30-45s scripts, 10 b-roll shotlists, and caption + hashtag sets.';
284};
285
286(async ()=>{
287 try{
288 await loadAgents();
289 await refresh();
290 setInterval(refresh, 5000);
291 }catch(e){
292 outEl.textContent = 'Failed loading cockpit: ' + String(e);
293 }
294})();
98295</script>
99</body></html>
100"""
296</body>
297</html>"""
101298 return HTMLResponse(html)
102299
300
301
302def agents_ping(req: Request):
303 if not _is_authed(req):
304 return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
305 items = _agent_list()
306 return JSONResponse({"ok": True, "ts": _now(), "count": len(items), "items": items})
307
308
309
310def agents_list(req: Request):
311 if not _is_authed(req):
312 return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
313 items = _agent_list()
314 return JSONResponse({"ok": True, "ts": _now(), "count": len(items), "items": items})
315
316
317
318async def agents_run(req: Request, body: AgentRun):
319 if not _is_authed(req):
320 return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
321
322 agent_id = (body.agentId or "").strip()
323 message = (body.message or "").strip()
324 if not agent_id or not message:
325 return JSONResponse({"ok": False, "error": "agentId and message required"}, status_code=400)
326
327 context = dict(body.context or {})
328 context["env"] = env_presence_report()
329
330 job = _add_job(kind=f"agent:{agent_id}", payload={"agentId": agent_id, "message": message[:400]})
331 try:
332 result = await _run_agent(agent_id, message, context)
333 job["status"] = "done"
334 job["result"] = result
335 return JSONResponse({"ok": True, "job": job, "result": result})
336 except Exception as e:
337 job["status"] = "error"
338 job["error"] = str(e)
339 return JSONResponse({"ok": False, "error": str(e), "job": job}, status_code=500)
340
341
103342
104def state():
105 return JSONResponse({"ok": True, "ts": _now(), "jobs": _JOBS[:15], "jobsCount": len(_JOBS),
106 "agents": {"ping": "/api/agents/ping", "repair": "/api/agents/repair", "patchBuilder": "/api/agents/patch-builder"}})
343def state(req: Request):
344 if not _is_authed(req):
345 return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
346 agents = _agent_list()
347 return JSONResponse(
348 {
349 "ok": True,
350 "ts": _now(),
351 "jobs": _JOBS[:20],
352 "jobsCount": len(_JOBS),
353 "agents": {"count": len(agents), "list": agents, "run": "/api/agents/run", "listEndpoint": "/api/agents/list"},
354 "env": env_presence_report(),
355 }
356 )
357
107358
108359
109async def run(body: CockpitRun, request: Request):
110 job = {"id": str(uuid.uuid4()), "ts": _now(), "kind": body.action, "payload": {"prompt": body.prompt or "", "meta": body.meta or {}}, "status":"queued"}
111 _JOBS.insert(0, job); del _JOBS[50:]
112 return JSONResponse({"ok": True, "job": job})
360async def run(req: Request, body: CockpitRun):
361 if not _is_authed(req):
362 return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
363
364 action = (body.action or "").strip()
365 prompt = (body.prompt or "").strip()
366 meta = body.meta or {}
367
368 # Backward compatible action runner.
369 action_map = {
370 "repair_pack": ("06_ops_reliability", "Diagnose and propose the minimum safe repair plan.\n\n" + prompt),
371 "patch_builder": ("02_api_engineer", "Propose a minimal patch plan (include unified diff if possible).\n\n" + prompt),
372 "dispatch_pr": ("09_release_manager", "Summarize changes and a safe release/rollout checklist.\n\n" + prompt),
373 "seo_run": ("01_dispatcher", "Create an SEO + content execution plan.\n\n" + prompt),
374 }
375
376 if action not in action_map:
377 return JSONResponse({"ok": False, "error": f"Unknown action: {action}"}, status_code=400)
378
379 agent_id, message = action_map[action]
380 context = {"from": "cockpit_action", "action": action, "meta": meta, "env": env_presence_report()}
381
382 job = _add_job(kind=action, payload={"prompt": prompt[:400], "meta": meta, "agentId": agent_id})
383 try:
384 result = await _run_agent(agent_id, message, context)
385 job["status"] = "done"
386 job["result"] = result
387 return JSONResponse({"ok": True, "job": job, "result": result})
388 except Exception as e:
389 job["status"] = "error"
390 job["error"] = str(e)
391 return JSONResponse({"ok": False, "error": str(e), "job": job}, status_code=500)
113392
114# cockpit_router is now included by server.py directly
Modifiedbackend/agents/08_security.md+2−1View fileUnifiedSplit
@@ -2,4 +2,5 @@
22You focus on safe defaults:
33- Never output secrets.
44- Require ADMIN_TOKEN for privileged operations.
5- Suggest password rotation if a secret was exposed.
\ No newline at end of file
5- Suggest password rotation if a secret was exposed.
6- Prefer descriptive MongoDB usernames (e.g. `hibiscus_app`) over generic names like `MONGO_URL` for clearer audit logs; see `docs/MONGODB_AUTHENTICATION_LOGS.md`.
Modifiedbackend/booking_routes.py+100−161View fileUnifiedSplit
@@ -14,6 +14,7 @@ from dotenv import load_dotenv
1414from pathlib import Path
1515from motor.motor_asyncio import AsyncIOMotorClient
1616from auth import get_current_user, verify_password, create_access_token, get_password_hash
17from config_env import get_db_name, get_mongo_url_with_source
1718
1819# Load environment variables
1920ROOT_DIR = Path(__file__).parent
@@ -42,9 +43,21 @@ logger = logging.getLogger(__name__)
4243router = APIRouter()
4344
4445# MongoDB connection
45mongo_url = os.environ['MONGO_URL']
46mongo_url, _mongo_src = get_mongo_url_with_source()
47if not mongo_url:
48 raise RuntimeError("MongoDB connection string not configured (set MONGO_URL or MONGO_URI in env)")
49
4650client = AsyncIOMotorClient(mongo_url)
47db = client[os.environ['DB_NAME']]
51db_name = get_db_name()
52if not db_name:
53 try:
54 db_name = client.get_default_database().name
55 except Exception:
56 db_name = None
57if not db_name:
58 raise RuntimeError("DB_NAME not configured and Mongo URI has no default database")
59
60db = client[db_name]
4861
4962# Stripe setup
5063stripe.api_key = os.environ.get('STRIPE_SECRET_KEY')
@@ -89,6 +102,9 @@ class AdminLogin(BaseModel):
89102 username: str
90103 password: str
91104
105class AdminKeyLogin(BaseModel):
106 key: str
107
92108class PasswordChange(BaseModel):
93109 current_password: str
94110 new_password: str
@@ -216,7 +232,7 @@ async def create_booking(booking: BookingCreate):
216232 # Check if this is an URGENT booking (within 24 hours)
217233 is_urgent, hours_until = is_urgent_booking(booking.date, booking.time)
218234 if is_urgent:
219 logger.warning(f"🚨 URGENT BOOKING DETECTED: {booking_ref} - only {hours_until}hrs notice!")
235 logger.warning(f"[URGENT] Booking detected: {booking_ref} - only {hours_until}hrs notice!")
220236 try:
221237 send_urgent_admin_email(booking_doc, hours_until)
222238 send_urgent_admin_sms(booking_doc, hours_until)
@@ -599,7 +615,7 @@ Questions? 021 743 321"""
599615 "payment_url": payment_url,
600616 "email_sent": email_sent,
601617 "sms_sent": sms_sent,
602 "message": f"Payment link sent! Email: {'✓' if email_sent else '✗'}, SMS: {'✓' if sms_sent else '✗'}"
618 "message": f"Payment link sent! Email: {'OK' if email_sent else 'FAIL'}, SMS: {'OK' if sms_sent else 'FAIL'}"
603619 }
604620
605621 except HTTPException:
@@ -687,6 +703,22 @@ async def admin_login(credentials: AdminLogin):
687703 logger.error(f"Login error: {str(e)}")
688704 raise HTTPException(status_code=500, detail=str(e))
689705
706
707
708async def admin_key_login(payload: AdminKeyLogin):
709 """
710 Break-glass admin login using ADMIN_API_KEY (Render env var).
711 This is intentionally simple: if you can set ADMIN_API_KEY in hosting,
712 you can regain access even if username/password or OAuth is broken.
713 """
714 expected = (os.environ.get("ADMIN_API_KEY") or "").strip()
715 provided = (payload.key or "").strip()
716 if not expected or provided != expected:
717 raise HTTPException(status_code=401, detail="Invalid admin key")
718
719 access_token = create_access_token(data={"sub": "admin_key", "auth_method": "admin_key"})
720 return {"access_token": access_token, "token_type": "bearer"}
721
690722
691723async def change_password(password_data: PasswordChange, current_user: dict = Depends(get_current_user)):
692724 try:
@@ -799,113 +831,20 @@ async def reset_password(request: PasswordResetConfirm):
799831
800832
801833async def google_auth_callback(request: Request, response: Response):
802 """Process Google OAuth session and create admin session"""
803 try:
804 body = await request.json()
805 session_id = body.get("session_id")
806
807 if not session_id:
808 raise HTTPException(status_code=400, detail="Session ID required")
809
810 logger.info(f"Processing Google OAuth for session_id: {session_id[:20]}...")
811
812 # Verify session with Emergent Auth
813 async with httpx.AsyncClient(timeout=30.0) as client:
814 auth_response = await client.get(
815 "https://demobackend.emergentagent.com/auth/v1/env/oauth/session-data",
816 headers={"X-Session-ID": session_id}
817 )
818
819 logger.info(f"Emergent Auth response status: {auth_response.status_code}")
820
821 if auth_response.status_code != 200:
822 error_detail = "Invalid or expired session"
823 try:
824 error_data = auth_response.json()
825 if "detail" in error_data:
826 error_detail = error_data["detail"].get("error_description", error_detail)
827 logger.error(f"Emergent Auth error: {error_data}")
828 except:
829 pass
830 raise HTTPException(status_code=401, detail=error_detail)
831
832 user_data = auth_response.json()
833 user_email = user_data.get("email", "").lower()
834
835 logger.info(f"Google OAuth user: {user_email}")
836
837 # Check if email is authorized
838 if user_email not in [e.lower() for e in AUTHORIZED_ADMIN_EMAILS]:
839 logger.warning(f"Unauthorized Google login attempt: {user_email}")
840 raise HTTPException(status_code=403, detail=f"This email ({user_email}) is not authorized for admin access. Contact administrator.")
841
842 # Create or update admin user
843 existing_admin = await db.admins.find_one({"email": user_email})
844
845 if not existing_admin:
846 # Create new admin from Google auth
847 admin_id = str(uuid.uuid4())
848 await db.admins.insert_one({
849 "id": admin_id,
850 "email": user_email,
851 "username": user_email.split("@")[0],
852 "name": user_data.get("name", "Admin"),
853 "picture": user_data.get("picture", ""),
854 "google_id": user_data.get("id"),
855 "auth_method": "google",
856 "createdAt": datetime.now(timezone.utc).isoformat()
857 })
858 else:
859 # Update existing admin
860 await db.admins.update_one(
861 {"email": user_email},
862 {"$set": {
863 "name": user_data.get("name", existing_admin.get("name", "Admin")),
864 "picture": user_data.get("picture", ""),
865 "google_id": user_data.get("id"),
866 "last_login": datetime.now(timezone.utc).isoformat()
867 }}
868 )
869
870 # Create JWT token
871 access_token = create_access_token(data={"sub": user_email, "auth_method": "google"})
872
873 # Store session token
874 session_token = user_data.get("session_token")
875 if session_token:
876 await db.admin_sessions.insert_one({
877 "email": user_email,
878 "session_token": session_token,
879 "expires_at": (datetime.now(timezone.utc) + timedelta(days=7)).isoformat(),
880 "created_at": datetime.now(timezone.utc).isoformat()
881 })
882
883 # Set cookie
884 response.set_cookie(
885 key="admin_session",
886 value=session_token,
887 httponly=True,
888 secure=True,
889 samesite="none",
890 max_age=7*24*60*60,
891 path="/"
892 )
893
894 logger.info(f"Google OAuth login successful for: {user_email}")
895 return {
896 "access_token": access_token,
897 "token_type": "bearer",
898 "user": {
899 "email": user_email,
900 "name": user_data.get("name"),
901 "picture": user_data.get("picture")
902 }
903 }
904 except HTTPException:
905 raise
906 except Exception as e:
907 logger.error(f"Google auth error: {str(e)}")
908 raise HTTPException(status_code=500, detail=str(e))
834 """
835 Deprecated.
836
837 This route previously depended on Emergent-hosted OAuth session data.
838 It is intentionally disabled to avoid sending operators to third-party auth.
839
840 Use:
841 - POST /api/admin/login (username/password), or
842 - POST /api/admin/key-login (ADMIN_API_KEY from backend hosting)
843 """
844 raise HTTPException(
845 status_code=410,
846 detail="Google login is disabled. Use username/password or /api/admin/key-login.",
847 )
909848
910849
911850async def get_admin_profile(request: Request):
@@ -1996,18 +1935,18 @@ async def send_driver_job_notification(booking: dict, driver: dict, payout: floa
19961935 booking_date = booking.get('date', 'N/A')
19971936 booking_time = booking.get('time', 'N/A')
19981937
1999 # Build acceptance URL
2000 base_url = os.environ.get('FRONTEND_URL', 'https://hibiscus-airport-1.preview.emergentagent.com')
1938 # Build acceptance URL (avoid Emergent preview defaults)
1939 base_url = (os.environ.get('FRONTEND_URL') or os.environ.get('PUBLIC_DOMAIN') or 'https://hibiscustoairport.co.nz').rstrip("/")
20011940 accept_url = f"{base_url}/driver/job/{booking.get('id')}?token={token}"
20021941
20031942 # Send Email to driver
20041943 if driver_email:
2005 subject = f"🚗 NEW JOB: {booking_ref} - ${payout:.2f}"
1944 subject = f"NEW JOB: {booking_ref} - ${payout:.2f}"
20061945
20071946 email_body = f"""
20081947 <div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
20091948 <div style="background: linear-gradient(135deg, #1f2937 0%, #111827 100%); color: white; padding: 30px; border-radius: 10px 10px 0 0;">
2010 <h1 style="margin: 0; font-size: 24px;">🚗 New Job Available</h1>
1949 <h1 style="margin: 0; font-size: 24px;">New Job Available</h1>
20111950 <p style="margin: 8px 0 0; color: #f59e0b;">Booking {booking_ref}</p>
20121951 </div>
20131952
@@ -2024,15 +1963,15 @@ async def send_driver_job_notification(booking: dict, driver: dict, payout: floa
20241963 </div>
20251964
20261965 <div style="background: #f8fafc; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #f59e0b;">
2027 <p style="margin: 0;"><strong>📅 Date:</strong> {booking_date}</p>
2028 <p style="margin: 10px 0 0;"><strong>â° Pickup Time:</strong> {booking_time}</p>
2029 <p style="margin: 10px 0 0;"><strong>📠Pickup:</strong> {booking.get('pickupAddress', 'N/A')}</p>
2030 <p style="margin: 10px 0 0;"><strong>ðŸÂ Drop-off:</strong> {booking.get('dropoffAddress', 'N/A')}</p>
2031 <p style="margin: 10px 0 0;"><strong>👥 Passengers:</strong> {booking.get('passengers', 1)}</p>
2032 <p style="margin: 10px 0 0;"><strong>👤 Customer:</strong> {booking.get('name', 'N/A')}</p>
1966 <p style="margin: 0;"><strong>Date:</strong> {booking_date}</p>
1967 <p style="margin: 10px 0 0;"><strong>Pickup Time:</strong> {booking_time}</p>
1968 <p style="margin: 10px 0 0;"><strong>Pickup:</strong> {booking.get('pickupAddress', 'N/A')}</p>
1969 <p style="margin: 10px 0 0;"><strong>Drop-off:</strong> {booking.get('dropoffAddress', 'N/A')}</p>
1970 <p style="margin: 10px 0 0;"><strong>Passengers:</strong> {booking.get('passengers', 1)}</p>
1971 <p style="margin: 10px 0 0;"><strong>Customer:</strong> {booking.get('name', 'N/A')}</p>
20331972 </div>
20341973
2035 {f'<div style="background: #e0f2fe; padding: 15px; border-radius: 8px; margin: 20px 0;"><p style="margin: 0; font-size: 14px; color: #0369a1;"><strong>📠Notes:</strong> {notes}</p></div>' if notes else ''}
1974 {f'<div style="background: #e0f2fe; padding: 15px; border-radius: 8px; margin: 20px 0;"><p style="margin: 0; font-size: 14px; color: #0369a1;"><strong>Notes:</strong> {notes}</p></div>' if notes else ''}
20361975
20371976 <div style="text-align: center; margin: 30px 0;">
20381977 <a href="{accept_url}" style="display: inline-block; background: #f59e0b; color: black; padding: 15px 40px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 16px;">
@@ -2152,7 +2091,7 @@ async def driver_respond_to_job(booking_id: str, data: dict):
21522091 from utils import send_email
21532092 send_email(
21542093 admin_email,
2155 f"✅ Driver ACCEPTED: {booking.get('booking_ref')}",
2094 f"Driver ACCEPTED: {booking.get('booking_ref')}",
21562095 f"<p><strong>{driver_name}</strong> has ACCEPTED job <strong>{booking.get('booking_ref')}</strong></p><p>Pickup: {booking.get('date')} at {booking.get('time')}</p>"
21572096 )
21582097
@@ -2182,7 +2121,7 @@ async def driver_respond_to_job(booking_id: str, data: dict):
21822121 from utils import send_email
21832122 send_email(
21842123 admin_email,
2185 f"âÂÅ’ Driver DECLINED: {booking.get('booking_ref')}",
2124 f"Driver DECLINED: {booking.get('booking_ref')}",
21862125 f"<p><strong>{driver_name}</strong> has DECLINED job <strong>{booking.get('booking_ref')}</strong></p><p>Reason: {decline_reason or 'No reason given'}</p><p>Please assign another driver.</p>"
21872126 )
21882127
@@ -2351,7 +2290,7 @@ async def send_5min_arrival_sms(tracking_session: dict, tracking_id: str):
23512290 # Format tracking URL (use production domain)
23522291 tracking_url = f"https://hibiscustoairport.co.nz/track/{booking_ref}"
23532292
2354 message = f"Hi {customer_name.split()[0]}! 🚗 Your driver {driver_name} is approximately 10 minutes away. Track live: {tracking_url}"
2293 message = f"Hi {customer_name.split()[0]}! Your driver {driver_name} is approximately 10 minutes away. Track live: {tracking_url}"
23552294
23562295 # Send via Twilio
23572296 account_sid = os.environ.get("TWILIO_ACCOUNT_SID", "")
@@ -2740,7 +2679,7 @@ At the end of your response, on a NEW LINE, add one of these tags:
27402679def fallback_response(session: WhatsAppSession, user_message: str) -> str:
27412680 """Fallback responses if AI fails"""
27422681 if session.state == "greeting":
2743 return "Hi! 👋 Welcome to Hibiscus to Airport! Where would you like to be picked up from?\n\n[NONE]"
2682 return "Hi! Welcome to Hibiscus to Airport! Where would you like to be picked up from?\n\n[NONE]"
27442683 elif session.state == "collecting_pickup":
27452684 return f"Thanks! And where are you heading to? (e.g., Auckland Airport)\n\n[EXTRACTED_PICKUP: {user_message}]"
27462685 elif session.state == "collecting_dropoff":
@@ -2808,7 +2747,7 @@ async def whatsapp_webhook(
28082747 if message.lower() in ['reset', 'start over', 'cancel', 'restart']:
28092748 reset_session(phone)
28102749 session = get_or_create_session(phone)
2811 response_text = "No problem! Let's start fresh. ðŸâ€â€ž\n\nWhere would you like to be picked up from?"
2750 response_text = "No problem! Let's start fresh.\n\nWhere would you like to be picked up from?"
28122751 session.state = "collecting_pickup"
28132752 else:
28142753 # Add user message to history
@@ -2851,13 +2790,13 @@ async def whatsapp_webhook(
28512790 session.state = "confirming"
28522791
28532792 # Add pricing info to response
2854 response_text += f"\n\n💰 **Your Quote:**\n"
2855 response_text += f"📠From: {session.pickup_address}\n"
2856 response_text += f"📠To: {session.dropoff_address}\n"
2857 response_text += f"📅 Date: {session.date}\n"
2858 response_text += f"â° Time: {session.time}\n"
2859 response_text += f"👥 Passengers: {session.passengers}\n"
2860 response_text += f"💵 **Total: ${session.pricing['totalPrice']:.2f} NZD**\n\n"
2793 response_text += f"\n\n**Your Quote:**\n"
2794 response_text += f"From: {session.pickup_address}\n"
2795 response_text += f"To: {session.dropoff_address}\n"
2796 response_text += f"Date: {session.date}\n"
2797 response_text += f"Time: {session.time}\n"
2798 response_text += f"Passengers: {session.passengers}\n"
2799 response_text += f"**Total: ${session.pricing['totalPrice']:.2f} NZD**\n\n"
28612800 response_text += "Reply 'BOOK' to confirm and receive payment link, or 'CHANGE' to modify details."
28622801 except Exception as e:
28632802 logger.error(f"Pricing calculation error: {str(e)}")
@@ -2897,12 +2836,12 @@ async def whatsapp_webhook(
28972836 public_domain = os.environ.get('PUBLIC_DOMAIN', 'https://hibiscustoairport.co.nz')
28982837 payment_url = f"{public_domain}/booking?pay={booking_id}"
28992838
2900 response_text = f"✅ **Booking Created!**\n\n"
2901 response_text += f"📋 Reference: **{booking_ref}**\n"
2902 response_text += f"💵 Total: **${session.pricing['totalPrice']:.2f} NZD**\n\n"
2903 response_text += f"💳 Pay securely here:\n{payment_url}\n\n"
2839 response_text = f"**Booking Created!**\n\n"
2840 response_text += f"Reference: **{booking_ref}**\n"
2841 response_text += f"Total: **${session.pricing['totalPrice']:.2f} NZD**\n\n"
2842 response_text += f"Pay securely here:\n{payment_url}\n\n"
29042843 response_text += "Or pay cash to the driver on pickup day.\n\n"
2905 response_text += "Questions? Just message us here! 😊"
2844 response_text += "Questions? Just message us here!"
29062845
29072846 session.state = "payment"
29082847
@@ -2985,15 +2924,15 @@ GOOGLE_CLIENT_SECRET = os.environ.get('GOOGLE_CLIENT_SECRET')
29852924GOOGLE_CALENDAR_ID = os.environ.get('GOOGLE_CALENDAR_ID', 'primary')
29862925GOOGLE_SCOPES = ['https://www.googleapis.com/auth/calendar']
29872926
2988# Get frontend URL for redirect
2989FRONTEND_URL = os.environ.get('FRONTEND_URL', 'https://hibiscus-airport-1.preview.emergentagent.com')
2927# Get frontend URL for redirect (avoid Emergent preview defaults)
2928FRONTEND_URL = (os.environ.get('FRONTEND_URL') or os.environ.get('PUBLIC_DOMAIN') or 'https://hibiscustoairport.co.nz').rstrip("/")
29902929
29912930@router.get("/calendar/auth/url")
2992async def get_calendar_auth_url(current_user: dict = Depends(get_current_user)):
2931async def get_calendar_auth_url(request: Request, current_user: dict = Depends(get_current_user)):
29932932 """Generate Google OAuth URL for calendar authorization"""
29942933 try:
29952934 # Build the redirect URI using the backend URL
2996 backend_url = os.environ.get('BACKEND_URL', 'https://hibiscus-airport-1.preview.emergentagent.com')
2935 backend_url = (os.environ.get('BACKEND_URL') or f"{request.url.scheme}://{request.url.netloc}").rstrip("/")
29972936 redirect_uri = f"{backend_url}/api/calendar/auth/callback"
29982937
29992938 # Build authorization URL
@@ -3014,7 +2953,7 @@ async def get_calendar_auth_url(current_user: dict = Depends(get_current_user)):
30142953 raise HTTPException(status_code=500, detail=str(e))
30152954
30162955@router.get("/calendar/auth/callback")
3017async def calendar_auth_callback(code: str = None, error: str = None):
2956async def calendar_auth_callback(request: Request, code: str = None, error: str = None):
30182957 """Handle Google OAuth callback and store tokens"""
30192958 try:
30202959 if error:
@@ -3025,7 +2964,7 @@ async def calendar_auth_callback(code: str = None, error: str = None):
30252964 return RedirectResponse(f"{FRONTEND_URL}/admin?calendar_error=no_code")
30262965
30272966 # Get backend URL for redirect_uri
3028 backend_url = os.environ.get('BACKEND_URL', 'https://hibiscus-airport-1.preview.emergentagent.com')
2967 backend_url = (os.environ.get('BACKEND_URL') or f"{request.url.scheme}://{request.url.netloc}").rstrip("/")
30292968 redirect_uri = f"{backend_url}/api/calendar/auth/callback"
30302969
30312970 # Exchange code for tokens using direct HTTP request
@@ -3204,28 +3143,28 @@ async def add_booking_to_google_calendar(booking: dict):
32043143 description = f"""
32053144BOOKING REFERENCE: {booking_ref}
32063145
3207👤 Customer: {booking.get('name', 'N/A')}
3208📞 Phone: {booking.get('phone', 'N/A')}
3209✉︠Email: {booking.get('email', 'N/A')}
3146Customer: {booking.get('name', 'N/A')}
3147Phone: {booking.get('phone', 'N/A')}
3148Email: {booking.get('email', 'N/A')}
32103149
3211📠Pickup: {booking.get('pickupAddress', 'N/A')}
3212ðŸÂ Drop-off: {booking.get('dropoffAddress', 'N/A')}
3150Pickup: {booking.get('pickupAddress', 'N/A')}
3151Drop-off: {booking.get('dropoffAddress', 'N/A')}
32133152
3214👥 Passengers: {booking.get('passengers', 1)}
3215💰 Total: ${booking.get('pricing', {}).get('totalPrice', 0):.2f} NZD
3153Passengers: {booking.get('passengers', 1)}
3154Total: ${booking.get('pricing', {}).get('totalPrice', 0):.2f} NZD
32163155
3217✈︠Flight Info:
3156Flight Info:
32183157- Departure: {booking.get('departureFlightNumber', 'N/A')} at {booking.get('departureTime', 'N/A')}
32193158- Arrival: {booking.get('arrivalFlightNumber', 'N/A')} at {booking.get('arrivalTime', 'N/A')}
32203159
3221📠Notes: {booking.get('notes', 'None')}
3160Notes: {booking.get('notes', 'None')}
32223161
32233162Status: {booking.get('status', 'pending').upper()}
32243163Payment: {booking.get('payment_status', 'pending').upper()}
32253164""".strip()
32263165
32273166 event = {
3228 'summary': f"🚗 {booking_ref} - {booking.get('name', 'Customer')} | {booking.get('passengers', 1)} pax",
3167 'summary': f"{booking_ref} - {booking.get('name', 'Customer')} | {booking.get('passengers', 1)} pax",
32293168 'location': booking.get('pickupAddress', ''),
32303169 'description': description,
32313170 'start': {
@@ -3279,7 +3218,7 @@ async def create_test_calendar_event(current_user: dict = Depends(get_current_us
32793218 end_datetime = tomorrow.replace(hour=11, minute=0, second=0, microsecond=0).isoformat()
32803219
32813220 event = {
3282 'summary': '🧪 Test Booking - Hibiscus to Airport',
3221 'summary': 'Test Booking - Hibiscus to Airport',
32833222 'location': 'Auckland Airport',
32843223 'description': 'This is a test event to verify Google Calendar integration is working correctly.',
32853224 'start': {
@@ -3318,12 +3257,12 @@ async def send_booking_reminder(booking: dict):
33183257 formatted_date = booking.get('date', 'N/A')
33193258
33203259 # Send reminder email
3321 subject = f"â° Reminder: Your Airport Transfer Tomorrow - {booking_ref}"
3260 subject = f"Reminder: Your Airport Transfer Tomorrow - {booking_ref}"
33223261
33233262 body = f"""
33243263 <div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
33253264 <div style="background: linear-gradient(135deg, #1f2937 0%, #111827 100%); color: white; padding: 30px; border-radius: 10px 10px 0 0;">
3326 <h1 style="margin: 0; font-size: 24px;">â° Transfer Reminder</h1>
3265 <h1 style="margin: 0; font-size: 24px;">Transfer Reminder</h1>
33273266 <p style="margin: 8px 0 0; color: #f59e0b;">Your transfer is tomorrow!</p>
33283267 </div>
33293268
@@ -3344,15 +3283,15 @@ async def send_booking_reminder(booking: dict):
33443283
33453284 <div style="background: #fef3c7; padding: 15px; border-radius: 8px; margin: 20px 0;">
33463285 <p style="margin: 0; font-size: 14px; color: #92400e;">
3347 <strong>📌 Please be ready 5-10 minutes before your pickup time.</strong><br>
3286 <strong>Please be ready 5-10 minutes before your pickup time.</strong><br>
33483287 Your driver will contact you when they are on their way.
33493288 </p>
33503289 </div>
33513290
33523291 <p style="font-size: 14px; color: #6b7280;">
33533292 If you need to make any changes, please contact us:<br>
3354 📞 021 743 321<br>
3355 ✉︠bookings@bookaride.co.nz
3293 021 743 321<br>
3294 bookings@bookaride.co.nz
33563295 </p>
33573296 </div>
33583297 </div>
Modifiedbackend/cockpit_routes.py+2−47View fileUnifiedSplit
@@ -24,50 +24,5 @@ def _is_authed(req: Request) -> bool:
2424def cockpit(req: Request):
2525 if not _is_authed(req):
2626 return RedirectResponse(url="/admin/login", status_code=302)
27
28 return HTMLResponse(f"""<!doctype html>
29<html>
30<head>
31 <meta charset="utf-8" />
32 <meta name="viewport" content="width=device-width,initial-scale=1" />
33 <title>Cockpit</title>
34 <style>
35 body{{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial; padding:18px;}}
36 .row{{display:flex; gap:10px; flex-wrap:wrap;}}
37 button{{padding:10px 12px; border-radius:10px; border:1px solid #e5e7eb; background:#111827; color:#fff; cursor:pointer;}}
38 pre{{background:#0b1020; color:#e5e7eb; padding:12px; border-radius:12px; overflow:auto;}}
39 .card{{border:1px solid #e5e7eb; border-radius:14px; padding:14px; margin-top:12px;}}
40 .meta{{color:#6b7280; font-size:13px;}}
41 </style>
42</head>
43<body>
44 <h2>Agent Cockpit</h2>
45 <div class="meta">Boot is green. This panel checks core endpoints and prepares agent automation.</div>
46
47 <div class="row" style="margin-top:10px;">
48 <button onclick="hit('/debug/stamp')">/debug/stamp</button>
49 <button onclick="hit('/api/agents/ping')">/api/agents/ping</button>
50 <button onclick="hit('/healthz')">/healthz</button>
51 </div>
52
53 <div class="card">
54 <div style="font-weight:700;">Output</div>
55 <pre id="out">(click a button)</pre>
56 </div>
57
58<script>
59async function hit(path){{
60 const out=document.getElementById('out');
61 out.textContent='Loading '+path+' ...';
62 try {{
63 const r = await fetch(path+'?ts='+(Date.now()));
64 const t = await r.text();
65 out.textContent = 'HTTP '+r.status+'\\n\\n'+t;
66 }} catch(e) {{
67 out.textContent = 'ERROR: '+String(e);
68 }}
69}}
70</script>
71
72</body>
73</html>""")
27 # The richer cockpit UI lives at /agent-cockpit; keep /admin/cockpit for the admin panel tab.
28 return RedirectResponse(url="/agent-cockpit", status_code=302)
Addedbackend/config_env.py+115−0View fileUnifiedSplit
@@ -0,0 +1,115 @@
1import os
2from typing import Dict, Optional, Tuple
3from urllib.parse import urlparse
4
5
6MONGO_URL_ENV_CANDIDATES = (
7 "MONGO_URL",
8 "MONGO_URI",
9 "MONGODB_URI",
10 "MONGODB_URL",
11 "DATABASE_URL",
12)
13
14
15def get_env_first(names) -> Tuple[Optional[str], Optional[str]]:
16 """
17 Return (value, env_name) for the first non-empty env var in `names`.
18 """
19 for n in names:
20 v = os.environ.get(n)
21 if v is None:
22 continue
23 v = v.strip()
24 if v:
25 return v, n
26 return None, None
27
28
29def get_mongo_url_with_source() -> Tuple[Optional[str], Optional[str]]:
30 """
31 Return (mongo_url, env_name). Accepts common env var names.
32 Only returns a value if it looks like a MongoDB URI.
33 """
34 raw, src = get_env_first(MONGO_URL_ENV_CANDIDATES)
35 if not raw:
36 return None, None
37 if raw.startswith("mongodb://") or raw.startswith("mongodb+srv://"):
38 return raw, src
39 # If DATABASE_URL is set to something else (e.g. postgres), ignore it.
40 return None, None
41
42
43def get_db_name() -> Optional[str]:
44 v = (os.environ.get("DB_NAME") or "").strip()
45 return v or None
46
47
48def mongo_uri_has_default_db(mongo_url: str) -> bool:
49 """
50 Best-effort check for a default DB in the URI path.
51 """
52 if not mongo_url:
53 return False
54 try:
55 parsed = urlparse(mongo_url)
56 path = (parsed.path or "").lstrip("/")
57 # mongodb+srv://.../dbname?...
58 return bool(path and path != "/")
59 except Exception:
60 return False
61
62
63def redact_mongo_url(mongo_url: str) -> str:
64 """
65 Return a safe-to-log version of a MongoDB URI.
66 Never returns user/pass or full query params.
67 """
68 if not mongo_url:
69 return ""
70 try:
71 parsed = urlparse(mongo_url)
72 scheme = parsed.scheme or "mongodb"
73 host = parsed.hostname or ""
74 port = f":{parsed.port}" if parsed.port else ""
75 db = (parsed.path or "").lstrip("/")
76 db_part = f"/{db}" if db else ""
77 return f"{scheme}://***@{host}{port}{db_part}"
78 except Exception:
79 return "mongodb://***"
80
81
82def env_presence_report() -> Dict[str, Dict[str, object]]:
83 """
84 Presence-only report (no secrets).
85 """
86 out: Dict[str, Dict[str, object]] = {}
87 for k in (
88 "DB_NAME",
89 "ADMIN_API_KEY",
90 "ADMIN_EMAIL",
91 "ADMIN_PHONE",
92 "SMTP_SERVER",
93 "SMTP_PORT",
94 "SMTP_USERNAME",
95 "SMTP_PASSWORD",
96 "SENDER_EMAIL",
97 "STRIPE_SECRET_KEY",
98 "TWILIO_ACCOUNT_SID",
99 "TWILIO_AUTH_TOKEN",
100 "TWILIO_PHONE_NUMBER",
101 "FRONTEND_URL",
102 "PUBLIC_DOMAIN",
103 "BACKEND_URL",
104 ):
105 out[k] = {"set": bool((os.environ.get(k) or "").strip())}
106
107 mongo_url, mongo_src = get_mongo_url_with_source()
108 out["MONGO_URL"] = {
109 "set": bool(mongo_url),
110 "source": mongo_src,
111 "candidates": list(MONGO_URL_ENV_CANDIDATES),
112 "has_default_db_in_uri": mongo_uri_has_default_db(mongo_url or ""),
113 }
114 return out
115
Modifiedbackend/server.py+133−4View fileUnifiedSplit
@@ -50,7 +50,12 @@ logger = logging.getLogger(__name__)
5050class NoCacheMiddleware(BaseHTTPMiddleware):
5151 async def dispatch(self, request: Request, call_next):
5252 response = await call_next(request)
53 if request.url.path.startswith("/api"):
53 # Prevent CDNs/browsers from caching admin/debug HTML too.
54 if (
55 request.url.path.startswith("/api")
56 or request.url.path.startswith("/admin")
57 or request.url.path.startswith("/debug")
58 ):
5459 response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0, private"
5560 response.headers["Pragma"] = "no-cache"
5661 response.headers["Expires"] = "0"
@@ -163,6 +168,10 @@ try:
163168 from apscheduler.schedulers.asyncio import AsyncIOScheduler
164169 from apscheduler.triggers.cron import CronTrigger
165170 from motor.motor_asyncio import AsyncIOMotorClient
171 import asyncio
172 import csv
173 import io
174 from config_env import get_db_name, get_mongo_url_with_source
166175
167176 scheduler = AsyncIOScheduler()
168177
@@ -170,8 +179,8 @@ try:
170179 """Send reminders for bookings happening tomorrow — runs daily at 6 PM NZ time."""
171180 try:
172181 logger.info("Running day-before reminder job...")
173 mongo_url = os.environ.get('MONGO_URL', '')
174 db_name = os.environ.get('DB_NAME', 'hibiscus_shuttle')
182 mongo_url, _mongo_src = get_mongo_url_with_source()
183 db_name = get_db_name() or os.environ.get('DB_NAME', 'hibiscus_shuttle')
175184 if not mongo_url:
176185 logger.warning("MONGO_URL not set, skipping reminders")
177186 return
@@ -242,6 +251,120 @@ try:
242251 except Exception as e:
243252 logger.error(f"Error in day-before reminder job: {e}")
244253
254 async def send_daily_booking_backup():
255 """
256 Email a lightweight CSV backup/digest to ADMIN_EMAIL.
257 Runs only if SMTP + ADMIN_EMAIL + Mongo are configured.
258 """
259 try:
260 admin_email = (os.environ.get("ADMIN_EMAIL") or "").strip()
261 if not admin_email:
262 logger.info("ADMIN_EMAIL not set, skipping daily backup email")
263 return
264
265 smtp_server = (os.environ.get("SMTP_SERVER") or "").strip()
266 smtp_user = (os.environ.get("SMTP_USERNAME") or "").strip()
267 smtp_pass = (os.environ.get("SMTP_PASSWORD") or "").strip()
268 sender = (os.environ.get("SENDER_EMAIL") or "").strip()
269 if not (smtp_server and smtp_user and smtp_pass and sender):
270 logger.info("SMTP not configured, skipping daily backup email")
271 return
272
273 mongo_url, _mongo_src = get_mongo_url_with_source()
274 if not mongo_url:
275 logger.info("MONGO_URL not set, skipping daily backup email")
276 return
277
278 db_name = get_db_name() or ""
279 client = AsyncIOMotorClient(mongo_url)
280 try:
281 if not db_name:
282 try:
283 db_name = client.get_default_database().name
284 except Exception:
285 db_name = ""
286 if not db_name:
287 logger.info("DB_NAME not set and no default DB in URI; skipping daily backup email")
288 return
289
290 db = client[db_name]
291 since = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat()
292
293 bookings = await db.bookings.find(
294 {"createdAt": {"$gte": since}}, {"_id": 0}
295 ).sort("createdAt", -1).to_list(5000)
296 deleted = await db.deleted_bookings.find(
297 {"deletedAt": {"$gte": since}}, {"_id": 0}
298 ).sort("deletedAt", -1).to_list(5000)
299
300 def to_csv(rows, fields):
301 buf = io.StringIO()
302 w = csv.DictWriter(buf, fieldnames=fields, extrasaction="ignore")
303 w.writeheader()
304 for r in rows:
305 if isinstance(r, dict):
306 w.writerow(r)
307 return buf.getvalue()
308
309 # Keep the export stable and not overly wide.
310 fields = [
311 "booking_ref",
312 "createdAt",
313 "date",
314 "time",
315 "name",
316 "email",
317 "phone",
318 "pickupAddress",
319 "dropoffAddress",
320 "passengers",
321 "status",
322 "payment_status",
323 "totalPrice",
324 ]
325
326 bookings_csv = to_csv(bookings, fields)
327 deleted_csv = to_csv(deleted, fields + ["deletedAt", "deletedBy", "restoredAt"])
328
329 subject = f"Daily bookings backup (last 7 days) - {datetime.now(timezone.utc).date().isoformat()}"
330 body = (
331 f"<p>Automated backup/digest from <b>{db_name}</b>.</p>"
332 f"<ul>"
333 f"<li>Active bookings (last 7d): <b>{len(bookings)}</b></li>"
334 f"<li>Deleted bookings (last 7d): <b>{len(deleted)}</b></li>"
335 f"</ul>"
336 f"<p>CSV attachments are included for redundancy.</p>"
337 )
338
339 from utils import send_email
340
341 attachments = [
342 {
343 "filename": f"bookings_last_7d_{db_name}.csv",
344 "content": bookings_csv,
345 "mime": "text/csv",
346 },
347 {
348 "filename": f"deleted_bookings_last_7d_{db_name}.csv",
349 "content": deleted_csv,
350 "mime": "text/csv",
351 },
352 ]
353
354 ok = await asyncio.to_thread(
355 send_email,
356 admin_email,
357 subject,
358 body,
359 None,
360 attachments,
361 )
362 logger.info(f"Daily backup email sent={ok} to {admin_email}")
363 finally:
364 client.close()
365 except Exception as e:
366 logger.error(f"Error in daily backup email job: {e}")
367
245368
246369 async def start_scheduler():
247370 scheduler.add_job(
@@ -250,8 +373,14 @@ try:
250373 id="day_before_reminders",
251374 replace_existing=True
252375 )
376 scheduler.add_job(
377 send_daily_booking_backup,
378 CronTrigger(hour=12, minute=10), # ~1:10 AM NZDT
379 id="daily_booking_backup",
380 replace_existing=True
381 )
253382 scheduler.start()
254 logger.info("Scheduler started - Day-before reminders will run at 6 PM NZ time daily")
383 logger.info("Scheduler started - reminders + daily backup email enabled when configured")
255384
256385
257386 async def shutdown_scheduler():
Modifiedbackend/utils.py+32−2View fileUnifiedSplit
@@ -330,14 +330,25 @@ def calculate_price(distance_km: float, passengers: int = 1, vip_pickup: bool =
330330 }
331331
332332# Email Notifications
333def send_email(to_email: str, subject: str, body: str, calendar_invite=None):
334 """Send email via Google Workspace SMTP with optional calendar invite"""
333def send_email(to_email: str, subject: str, body: str, calendar_invite=None, attachments=None):
334 """
335 Send email via Google Workspace SMTP with optional calendar invite and attachments.
336
337 attachments: optional list of dicts:
338 - filename: str
339 - content: str|bytes
340 - mime: str (e.g. "text/csv" or "application/json")
341 """
335342 try:
336343 smtp_server = os.environ.get('SMTP_SERVER')
337344 smtp_port = int(os.environ.get('SMTP_PORT', 587))
338345 smtp_username = os.environ.get('SMTP_USERNAME')
339346 smtp_password = os.environ.get('SMTP_PASSWORD')
340347 sender_email = os.environ.get('SENDER_EMAIL')
348
349 if not smtp_server or not smtp_username or not smtp_password or not sender_email:
350 logger.warning("SMTP env vars missing - cannot send email")
351 return False
341352
342353 msg = MIMEMultipart('mixed')
343354 msg['From'] = sender_email
@@ -362,6 +373,25 @@ def send_email(to_email: str, subject: str, body: str, calendar_invite=None):
362373 encoders.encode_base64(ical_part)
363374 ical_part.add_header('Content-Disposition', 'attachment', filename='booking.ics')
364375 msg.attach(ical_part)
376
377 # Attach extra files (e.g., CSV backups)
378 if attachments:
379 for a in attachments:
380 try:
381 filename = (a or {}).get("filename") or "attachment.bin"
382 mime = (a or {}).get("mime") or "application/octet-stream"
383 content = (a or {}).get("content") or b""
384 if isinstance(content, str):
385 content = content.encode("utf-8")
386
387 maintype, subtype = (mime.split("/", 1) + ["octet-stream"])[:2]
388 part = MIMEBase(maintype, subtype)
389 part.set_payload(content)
390 encoders.encode_base64(part)
391 part.add_header("Content-Disposition", "attachment", filename=filename)
392 msg.attach(part)
393 except Exception as attach_err:
394 logger.error(f"Failed attaching {a}: {attach_err}")
365395
366396 # Send email
367397 with smtplib.SMTP(smtp_server, smtp_port) as server:
Addeddocs/MONGODB_AUTHENTICATION_LOGS.md+86−0View fileUnifiedSplit
@@ -0,0 +1,86 @@
1# MongoDB Authentication Logs - Analysis & Reference
2
3This document explains how to interpret MongoDB Atlas authentication logs and summarizes findings from recent logs.
4
5## Log Format
6
7| Column | Description |
8|--------|-------------|
9| **Timestamp** | When the authentication occurred |
10| **Username** | The authenticated identity (MongoDB user or external principal) |
11| **IP Address** | Source IP of the connection |
12| **Host** | MongoDB Atlas host that received the connection |
13| **Authentication Source** | Database used for auth (`admin`, `$external`, etc.) |
14| **Authentication Result** | Success or failure |
15
16---
17
18## Sample Log Analysis (hibiscustoairport cluster)
19
20### Two Authentication Sources
21
22#### 1. Application / Backend (`MONGO_URL` -> `admin`)
23
24| Username | Auth Source | Typical Use |
25|----------|-------------|-------------|
26| `MONGO_URL` | `admin` | Backend server using connection string from `MONGO_URL` env var |
27
28- **Source IP:** `74.220.49.253` (hosting provider egress / deployment)
29- **Connection string format:** `mongodb+srv://MONGO_URL:<password>@hibiscustoairport-shard-00-02.vte8b8.mongodb.net/...`
30
31> **Note:** If Atlas logs show the username as `MONGO_URL`, that means the MongoDB user in your connection string is literally named `MONGO_URL`.
32> Consider creating a dedicated user (e.g. `hibiscus_app` or `bookaride_backend`) for clearer audit trails and easier credential rotation.
33
34#### 2. Human / Atlas UI (`CN=ccantynz@gmail.com` -> `$external`)
35
36| Username | Auth Source | Typical Use |
37|----------|-------------|-------------|
38| `CN=ccantynz@gmail.com` | `$external` | MongoDB Atlas UI, Compass, or CLI using Google/LDAP/X.509 |
39
40- **Source IPs:** `13.238.145.51`, `54.252.174.158` (AWS ap-southeast-2 - Australia)
41- **Auth method:** External (e.g. Google OAuth, X.509, or LDAP)
42
43---
44
45## IP Address Summary
46
47| IP | Likely Origin | Used By |
48|----|---------------|---------|
49| `74.220.49.253` | Hosting provider egress | Backend (`MONGO_URL`) |
50| `13.238.145.51` | AWS ap-southeast-2 | Atlas UI / Compass (`CN=ccantynz@gmail.com`) |
51| `54.252.174.158` | AWS ap-southeast-2 | Atlas UI / Compass (`CN=ccantynz@gmail.com`) |
52
53---
54
55## Security Recommendations
56
571. **Use descriptive usernames** - Prefer `hibiscus_app` or `bookaride_backend` over `MONGO_URL` for application connections. This improves audit clarity and avoids confusion with env var names.
58
592. **Rotate credentials** - If `MONGO_URL` or any credential may have been exposed, rotate the MongoDB user password and update `MONGO_URL` in Render/hosting.
60
613. **Restrict IP access** - In MongoDB Atlas -> Network Access, consider limiting allowed IPs to known deployment and admin IPs (e.g. hosting egress IPs, your office/VPN).
62
634. **Monitor failed logins** - Watch for `Authentication Result: Failed` entries; repeated failures from unknown IPs may indicate brute-force attempts.
64
655. **Separate admin and app users** - Use different MongoDB users for:
66 - Application (read/write to app DB only)
67 - Admin/bootstrap (for maintenance tooling)
68 - Atlas UI access (your personal `$external` identity)
69
70---
71
72## Where to Find These Logs
73
741. Log in to [MongoDB Atlas](https://cloud.mongodb.com)
752. Select your project and cluster (`hibiscustoairport`)
763. Go to **Security** -> **Authentication** or **Monitoring** -> **Logs**
774. Filter by authentication events
78
79---
80
81## Related Configuration
82
83- **Backend:** `MONGO_URL` env var (Render, local `.env`)
84- **Code:** `backend/database.py`, `backend/booking_routes.py`, `backend/admin_routes.py`
85- **Security agent:** `backend/agents/08_security.md`
86
Modifiedfrontend/src/pages/AdminLogin.jsx+55−118View fileUnifiedSplit
@@ -1,7 +1,7 @@
1import React, { useState, useEffect, useRef } from 'react';
1import React, { useState, useEffect } from 'react';
22import { Button } from '../components/ui/button';
33import { Input } from '../components/ui/input';
4import { useNavigate, useLocation } from 'react-router-dom';
4import { useNavigate } from 'react-router-dom';
55import { useToast } from '../hooks/use-toast';
66import axios from 'axios';
77import { Loader2, Mail, Lock, ArrowLeft } from 'lucide-react';
@@ -9,114 +9,46 @@ import { Loader2, Mail, Lock, ArrowLeft } from 'lucide-react';
99import { BACKEND_URL } from '../config';
1010
1111
12// Separate component to handle OAuth callback
13const GoogleAuthCallback = ({ onSuccess, onError }) => {
14 const hasProcessed = useRef(false);
15 const { toast } = useToast();
16
17 useEffect(() => {
18 // Prevent double processing in StrictMode
19 if (hasProcessed.current) return;
20 hasProcessed.current = true;
21
22 const processAuth = async () => {
23 try {
24 const hash = window.location.hash;
25 const sessionId = hash.split('session_id=')[1]?.split('&')[0];
26
27 if (!sessionId) {
28 throw new Error('No session ID found');
29 }
30
31 console.log('Processing Google OAuth with session_id:', sessionId.substring(0, 20) + '...');
32
33 const response = await axios.post(`${BACKEND_URL}/api/admin/google-auth`, {
34 session_id: sessionId
35 });
36
37 // Clear the hash immediately
38 window.history.replaceState(null, '', window.location.pathname);
39
40 localStorage.setItem('admin_token', response.data.access_token);
41
42 toast({
43 title: 'Welcome!',
44 description: `Signed in as ${response.data.user.email}`
45 });
46
47 onSuccess(response.data);
48 } catch (error) {
49 console.error('Google auth error:', error);
50
51 // Clear the hash
52 window.history.replaceState(null, '', window.location.pathname);
53
54 const message = error.response?.data?.detail || 'Google authentication failed';
55 toast({
56 title: 'Access Denied',
57 description: message,
58 variant: 'destructive'
59 });
60
61 onError(message);
62 }
63 };
64
65 processAuth();
66 }, [onSuccess, onError, toast]);
67
68 return (
69 <div className="min-h-screen bg-gradient-to-br from-black via-gray-900 to-black flex items-center justify-center p-4">
70 <div className="text-center">
71 <Loader2 className="w-12 h-12 animate-spin text-gold mx-auto mb-4" />
72 <p className="text-white text-lg">Signing you in with Google...</p>
73 <p className="text-gray-400 text-sm mt-2">Please wait...</p>
74 </div>
75 </div>
76 );
77};
78
7912const AdminLogin = () => {
8013 const navigate = useNavigate();
81 const location = useLocation();
8214 const { toast } = useToast();
8315 const [credentials, setCredentials] = useState({ username: '', password: '' });
8416 const [loading, setLoading] = useState(false);
17 const [keyLoading, setKeyLoading] = useState(false);
18 const [adminKey, setAdminKey] = useState('');
8519 const [showForgotPassword, setShowForgotPassword] = useState(false);
8620 const [resetEmail, setResetEmail] = useState('');
8721 const [resetLoading, setResetLoading] = useState(false);
8822 const [resetSent, setResetSent] = useState(false);
8923 const [authError, setAuthError] = useState('');
9024
91 // CRITICAL: Check for session_id synchronously during render
92 const hash = typeof window !== 'undefined' ? window.location.hash : '';
93 const hasSessionId = hash && hash.includes('session_id=');
94
9525 // Check if already logged in
9626 useEffect(() => {
9727 const token = localStorage.getItem('admin_token');
98 if (token && !hasSessionId) {
28 if (token) {
9929 navigate('/admin/dashboard');
10030 }
101 }, [navigate, hasSessionId]);
102
103 const handleAuthSuccess = (data) => {
104 navigate('/admin/dashboard');
105 };
31 }, [navigate]);
10632
107 const handleAuthError = (message) => {
108 setAuthError(message);
109 };
110
111 // If we have a session_id, show the callback handler
112 if (hasSessionId) {
113 return <GoogleAuthCallback onSuccess={handleAuthSuccess} onError={handleAuthError} />;
114 }
115
116 const handleGoogleLogin = () => {
117 // Use window.location.origin to get the current domain dynamically
118 const redirectUrl = window.location.origin + '/admin/login';
119 window.location.href = `https://auth.emergentagent.com/?redirect=${encodeURIComponent(redirectUrl)}`;
33 const handleKeyLogin = async () => {
34 setKeyLoading(true);
35 setAuthError('');
36 try {
37 const response = await axios.post(`${BACKEND_URL}/api/admin/key-login`, { key: adminKey });
38 localStorage.setItem('admin_token', response.data.access_token);
39 toast({ title: 'Login Successful!', description: 'Signed in with admin key' });
40 navigate('/admin/dashboard');
41 } catch (error) {
42 const message = error.response?.data?.detail || 'Invalid admin key';
43 setAuthError(message);
44 toast({
45 title: 'Login Failed',
46 description: message,
47 variant: 'destructive'
48 });
49 } finally {
50 setKeyLoading(false);
51 }
12052 };
12153
12254 const handleSubmit = async (e) => {
@@ -274,31 +206,36 @@ const AdminLogin = () => {
274206 </div>
275207 )}
276208
277 {/* Google Login Button */}
278 <button
279 onClick={handleGoogleLogin}
280 className="w-full flex items-center justify-center gap-3 bg-white hover:bg-gray-100 text-gray-800 font-medium py-4 px-6 rounded-xl transition-all mb-6 shadow-lg"
281 >
282 <svg className="w-5 h-5" viewBox="0 0 24 24">
283 <path
284 fill="#4285F4"
285 d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
286 />
287 <path
288 fill="#34A853"
289 d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
290 />
291 <path
292 fill="#FBBC05"
293 d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
294 />
295 <path
296 fill="#EA4335"
297 d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
298 />
299 </svg>
300 Continue with Google
301 </button>
209 {/* Emergency Admin Key Login (no third-party OAuth) */}
210 <div className="space-y-3 mb-6">
211 <label className="block text-sm font-medium text-gray-300">Emergency Admin Key</label>
212 <Input
213 type="password"
214 value={adminKey}
215 onChange={(e) => setAdminKey(e.target.value)}
216 placeholder="Paste ADMIN_API_KEY"
217 className="bg-black/50 border-gold/30 text-white"
218 />
219 <Button
220 type="button"
221 onClick={handleKeyLogin}
222 disabled={keyLoading || !adminKey.trim()}
223 className="w-full bg-white hover:bg-gray-100 text-gray-900 font-bold py-6"
224 >
225 {keyLoading ? (
226 <>
227 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
228 Logging in...
229 </>
230 ) : (
231 'Login with Admin Key'
232 )}
233 </Button>
234 <p className="text-xs text-gray-400">
235 If you do not know this key, set <span className="text-gray-200">ADMIN_API_KEY</span> in your backend hosting
236 (Render) and redeploy.
237 </p>
238 </div>
302239
303240 <div className="relative mb-6">
304241 <div className="absolute inset-0 flex items-center">
305242
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts