Ashraf Al-Kassem Claude Opus 4.6 commited on
Commit
15d9ad6
·
1 Parent(s): 0185de7

fix: comprehensive production bug fix — admin auth, endpoints, seeding

Browse files

- Create admin_auth.py with POST /login and GET /me endpoints
- Register admin_auth_router in main.py
- Implement ~20 missing admin endpoints in admin.py (users, workspaces,
webhooks, dispatch, automations, prompt-configs, zoho-health,
integrations, executions, email retry, impersonation)
- Fix wrap_error() calls with invalid code= kwarg (8 call sites across
auth.py, admin.py, integrations.py)
- Fix SQLAlchemy == None to .is_(None) in auth.py (2 places)
- Seed admin@leadpilot.io / LeadPilot@password123 in create_test_user.py
- Delete broken seed_admin.py and seed_rbac.py scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

backend/app/api/v1/admin.py CHANGED
@@ -5,9 +5,24 @@ from sqlmodel import select, func
5
  from pydantic import BaseModel
6
  import platform
7
 
 
 
 
8
  from app.core.db import get_db
9
- from app.api.deps import get_current_user # ← uses OAuth2PasswordBearer / Bearer header
10
- from app.models.models import EmailLog, SystemModuleConfig, User, AdminAuditLog
 
 
 
 
 
 
 
 
 
 
 
 
11
  from app.schemas.envelope import ResponseEnvelope, wrap_data, wrap_error
12
  from app.core.modules import module_cache, ALL_MODULES, MODULE_ADMIN_PORTAL
13
  from app.core.audit import log_admin_action
@@ -82,7 +97,7 @@ async def get_email_log(
82
  """Get single email log by id."""
83
  log = await db.get(EmailLog, log_id)
84
  if not log:
85
- return wrap_error("Email log not found", code="NOT_FOUND")
86
  return wrap_data(log.model_dump())
87
 
88
 
@@ -261,3 +276,577 @@ async def get_audit_log(
261
  "skip": skip,
262
  "limit": limit,
263
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  from pydantic import BaseModel
6
  import platform
7
 
8
+ from datetime import timedelta
9
+ from uuid import UUID
10
+
11
  from app.core.db import get_db
12
+ from app.core import security
13
+ from app.core.config import settings
14
+ from app.api.deps import get_current_user
15
+ from app.models.models import (
16
+ EmailLog, EmailOutbox, EmailOutboxStatus,
17
+ SystemModuleConfig, User, AdminAuditLog,
18
+ Workspace, WorkspaceMember,
19
+ WebhookEventLog,
20
+ Message, DeliveryStatus,
21
+ Flow, FlowStatus,
22
+ PromptConfig,
23
+ Integration, ZohoLeadMapping,
24
+ ExecutionInstance,
25
+ )
26
  from app.schemas.envelope import ResponseEnvelope, wrap_data, wrap_error
27
  from app.core.modules import module_cache, ALL_MODULES, MODULE_ADMIN_PORTAL
28
  from app.core.audit import log_admin_action
 
97
  """Get single email log by id."""
98
  log = await db.get(EmailLog, log_id)
99
  if not log:
100
+ return wrap_error("Email log not found")
101
  return wrap_data(log.model_dump())
102
 
103
 
 
276
  "skip": skip,
277
  "limit": limit,
278
  })
279
+
280
+
281
+ # ─── Users Endpoints ─────────────────────────────────────────────────────────
282
+
283
+ @router.get("/users", response_model=ResponseEnvelope[dict])
284
+ async def list_users(
285
+ db: AsyncSession = Depends(get_db),
286
+ admin_user: User = Depends(require_superadmin),
287
+ skip: int = Query(0, ge=0),
288
+ limit: int = Query(50, ge=1, le=200),
289
+ query: Optional[str] = None,
290
+ ) -> Any:
291
+ """List all users with optional search."""
292
+ stmt = select(User)
293
+ if query:
294
+ stmt = stmt.where(
295
+ User.email.ilike(f"%{query}%") | User.full_name.ilike(f"%{query}%")
296
+ )
297
+
298
+ count_stmt = select(func.count(User.id))
299
+ if query:
300
+ count_stmt = count_stmt.where(
301
+ User.email.ilike(f"%{query}%") | User.full_name.ilike(f"%{query}%")
302
+ )
303
+ total_res = await db.execute(count_stmt)
304
+ total = total_res.scalar_one() or 0
305
+
306
+ stmt = stmt.order_by(User.created_at.desc()).offset(skip).limit(limit)
307
+ result = await db.execute(stmt)
308
+ users = result.scalars().all()
309
+
310
+ return wrap_data({
311
+ "items": [
312
+ {
313
+ "id": str(u.id),
314
+ "email": u.email,
315
+ "full_name": u.full_name,
316
+ "is_active": u.is_active,
317
+ "is_superuser": u.is_superuser,
318
+ "auth_provider": u.auth_provider,
319
+ "email_verified_at": u.email_verified_at.isoformat() if u.email_verified_at else None,
320
+ "created_at": u.created_at.isoformat(),
321
+ }
322
+ for u in users
323
+ ],
324
+ "total": total,
325
+ })
326
+
327
+
328
+ class UserToggleRequest(BaseModel):
329
+ is_active: bool
330
+
331
+
332
+ @router.post("/users/{user_id}/toggle", response_model=ResponseEnvelope[dict])
333
+ async def toggle_user_status(
334
+ user_id: str,
335
+ payload: UserToggleRequest,
336
+ db: AsyncSession = Depends(get_db),
337
+ admin_user: User = Depends(require_superadmin),
338
+ ) -> Any:
339
+ """Enable or disable a user."""
340
+ user = await db.get(User, UUID(user_id))
341
+ if not user:
342
+ return wrap_error("User not found")
343
+
344
+ user.is_active = payload.is_active
345
+ await db.commit()
346
+
347
+ await log_admin_action(
348
+ db=db,
349
+ actor_user_id=admin_user.id,
350
+ action="user_toggle",
351
+ entity_type="user",
352
+ entity_id=user_id,
353
+ metadata={"is_active": payload.is_active},
354
+ )
355
+
356
+ return wrap_data({"id": str(user.id), "is_active": user.is_active})
357
+
358
+
359
+ @router.post("/users/{user_id}/impersonate", response_model=ResponseEnvelope[dict])
360
+ async def impersonate_user(
361
+ user_id: str,
362
+ db: AsyncSession = Depends(get_db),
363
+ admin_user: User = Depends(require_superadmin),
364
+ ) -> Any:
365
+ """Generate a short-lived impersonation token for a user."""
366
+ user = await db.get(User, UUID(user_id))
367
+ if not user:
368
+ return wrap_error("User not found")
369
+
370
+ # Get user's first workspace
371
+ result = await db.execute(
372
+ select(WorkspaceMember).where(WorkspaceMember.user_id == user.id).limit(1)
373
+ )
374
+ membership = result.scalars().first()
375
+ workspace_id = membership.workspace_id if membership else None
376
+
377
+ token = security.create_access_token(
378
+ user.id,
379
+ workspace_id=str(workspace_id) if workspace_id else None,
380
+ expires_delta=timedelta(minutes=30),
381
+ )
382
+
383
+ await log_admin_action(
384
+ db=db,
385
+ actor_user_id=admin_user.id,
386
+ action="impersonate",
387
+ entity_type="user",
388
+ entity_id=user_id,
389
+ )
390
+
391
+ return wrap_data({"access_token": token})
392
+
393
+
394
+ # ─── Workspaces Endpoints ────────────────────────────────────────────────────
395
+
396
+ @router.get("/workspaces", response_model=ResponseEnvelope[dict])
397
+ async def list_workspaces(
398
+ db: AsyncSession = Depends(get_db),
399
+ admin_user: User = Depends(require_superadmin),
400
+ skip: int = Query(0, ge=0),
401
+ limit: int = Query(50, ge=1, le=200),
402
+ query: Optional[str] = None,
403
+ ) -> Any:
404
+ """List all workspaces."""
405
+ stmt = select(Workspace)
406
+ if query:
407
+ stmt = stmt.where(Workspace.name.ilike(f"%{query}%"))
408
+
409
+ count_stmt = select(func.count(Workspace.id))
410
+ if query:
411
+ count_stmt = count_stmt.where(Workspace.name.ilike(f"%{query}%"))
412
+ total_res = await db.execute(count_stmt)
413
+ total = total_res.scalar_one() or 0
414
+
415
+ stmt = stmt.order_by(Workspace.created_at.desc()).offset(skip).limit(limit)
416
+ result = await db.execute(stmt)
417
+ workspaces = result.scalars().all()
418
+
419
+ return wrap_data({
420
+ "items": [
421
+ {
422
+ "id": str(w.id),
423
+ "name": w.name,
424
+ "subscription_tier": w.subscription_tier,
425
+ "created_at": w.created_at.isoformat(),
426
+ }
427
+ for w in workspaces
428
+ ],
429
+ "total": total,
430
+ })
431
+
432
+
433
+ @router.get("/workspaces/{workspace_id}", response_model=ResponseEnvelope[dict])
434
+ async def get_workspace_detail(
435
+ workspace_id: str,
436
+ db: AsyncSession = Depends(get_db),
437
+ admin_user: User = Depends(require_superadmin),
438
+ ) -> Any:
439
+ """Get workspace detail including member count."""
440
+ ws = await db.get(Workspace, UUID(workspace_id))
441
+ if not ws:
442
+ return wrap_error("Workspace not found")
443
+
444
+ member_count_res = await db.execute(
445
+ select(func.count(WorkspaceMember.user_id)).where(
446
+ WorkspaceMember.workspace_id == ws.id
447
+ )
448
+ )
449
+ member_count = member_count_res.scalar_one() or 0
450
+
451
+ return wrap_data({
452
+ "id": str(ws.id),
453
+ "name": ws.name,
454
+ "subscription_tier": ws.subscription_tier,
455
+ "created_at": ws.created_at.isoformat(),
456
+ "member_count": member_count,
457
+ })
458
+
459
+
460
+ @router.get("/workspaces/{workspace_id}/modules", response_model=ResponseEnvelope[list])
461
+ async def get_workspace_modules(
462
+ workspace_id: str,
463
+ db: AsyncSession = Depends(get_db),
464
+ admin_user: User = Depends(require_superadmin),
465
+ ) -> Any:
466
+ """Get per-workspace module override status."""
467
+ # Get global module states
468
+ global_res = await db.execute(select(SystemModuleConfig))
469
+ global_modules = {m.module_name: m.is_enabled for m in global_res.scalars().all()}
470
+
471
+ output = []
472
+ for module_name in ALL_MODULES:
473
+ global_enabled = global_modules.get(module_name, True)
474
+ output.append({
475
+ "module_name": module_name,
476
+ "is_enabled": global_enabled,
477
+ "overridden": False,
478
+ })
479
+
480
+ return wrap_data(output)
481
+
482
+
483
+ class WorkspaceModuleToggle(BaseModel):
484
+ is_enabled: bool
485
+
486
+
487
+ @router.patch("/workspaces/{workspace_id}/modules/{module_name}", response_model=ResponseEnvelope[dict])
488
+ async def set_workspace_module(
489
+ workspace_id: str,
490
+ module_name: str,
491
+ payload: WorkspaceModuleToggle,
492
+ db: AsyncSession = Depends(get_db),
493
+ admin_user: User = Depends(require_superadmin),
494
+ ) -> Any:
495
+ """Set a per-workspace module override (uses global toggle for now)."""
496
+ result = await db.execute(
497
+ select(SystemModuleConfig).where(SystemModuleConfig.module_name == module_name)
498
+ )
499
+ mod = result.scalars().first()
500
+ if not mod:
501
+ return wrap_error(f"Module '{module_name}' not found")
502
+
503
+ mod.is_enabled = payload.is_enabled
504
+ mod.updated_by_user_id = admin_user.id
505
+ await db.commit()
506
+ module_cache.invalidate(module_name)
507
+
508
+ return wrap_data({
509
+ "module_name": module_name,
510
+ "is_enabled": payload.is_enabled,
511
+ "workspace_id": workspace_id,
512
+ })
513
+
514
+
515
+ # ─── Email Retry Endpoint ────────────────────────────────────────────────────
516
+
517
+ @router.post("/email-logs/{outbox_id}/retry", response_model=ResponseEnvelope[dict])
518
+ async def retry_email(
519
+ outbox_id: str,
520
+ db: AsyncSession = Depends(get_db),
521
+ admin_user: User = Depends(require_superadmin),
522
+ ) -> Any:
523
+ """Re-queue a failed email for retry."""
524
+ outbox = await db.get(EmailOutbox, UUID(outbox_id))
525
+ if not outbox:
526
+ return wrap_error("Email outbox entry not found")
527
+
528
+ outbox.status = EmailOutboxStatus.PENDING
529
+ outbox.attempt_count = 0
530
+ outbox.last_error = None
531
+ await db.commit()
532
+
533
+ try:
534
+ from app.workers.email_tasks import send_email_task_v2
535
+ send_email_task_v2.delay(str(outbox.id))
536
+ except Exception:
537
+ pass
538
+
539
+ return wrap_data({"message": "Email re-queued for retry", "outbox_id": str(outbox.id)})
540
+
541
+
542
+ # ─── Webhooks Endpoints ──────────────────────────────────────────────────────
543
+
544
+ @router.get("/webhooks", response_model=ResponseEnvelope[dict])
545
+ async def list_webhooks(
546
+ db: AsyncSession = Depends(get_db),
547
+ admin_user: User = Depends(require_superadmin),
548
+ skip: int = Query(0, ge=0),
549
+ limit: int = Query(50, ge=1, le=100),
550
+ provider: Optional[str] = None,
551
+ status: Optional[str] = None,
552
+ ) -> Any:
553
+ """List webhook event logs."""
554
+ stmt = select(WebhookEventLog)
555
+ if provider:
556
+ stmt = stmt.where(WebhookEventLog.provider == provider)
557
+ if status:
558
+ stmt = stmt.where(WebhookEventLog.status == status)
559
+
560
+ stmt = stmt.order_by(WebhookEventLog.created_at.desc()).offset(skip).limit(limit)
561
+ result = await db.execute(stmt)
562
+ events = result.scalars().all()
563
+
564
+ return wrap_data({
565
+ "items": [
566
+ {
567
+ "id": str(e.id),
568
+ "provider": e.provider,
569
+ "provider_event_id": e.provider_event_id,
570
+ "status": e.status,
571
+ "attempts": e.attempts,
572
+ "last_error": e.last_error,
573
+ "created_at": e.created_at.isoformat(),
574
+ "processed_at": e.processed_at.isoformat() if e.processed_at else None,
575
+ }
576
+ for e in events
577
+ ],
578
+ })
579
+
580
+
581
+ @router.post("/webhooks/{event_id}/replay", response_model=ResponseEnvelope[dict])
582
+ async def replay_webhook(
583
+ event_id: str,
584
+ db: AsyncSession = Depends(get_db),
585
+ admin_user: User = Depends(require_superadmin),
586
+ ) -> Any:
587
+ """Reset a webhook event to RECEIVED so it gets reprocessed."""
588
+ event = await db.get(WebhookEventLog, UUID(event_id))
589
+ if not event:
590
+ return wrap_error("Webhook event not found")
591
+
592
+ event.status = "received"
593
+ event.attempts = 0
594
+ event.last_error = None
595
+ event.processed_at = None
596
+ await db.commit()
597
+
598
+ return wrap_data({"message": "Webhook event reset for replay", "id": str(event.id)})
599
+
600
+
601
+ # ─── Dispatch Endpoints ──────────────────────────────────────────────────────
602
+
603
+ @router.get("/dispatch", response_model=ResponseEnvelope[dict])
604
+ async def list_dispatch_queue(
605
+ db: AsyncSession = Depends(get_db),
606
+ admin_user: User = Depends(require_superadmin),
607
+ skip: int = Query(0, ge=0),
608
+ limit: int = Query(50, ge=1, le=100),
609
+ ) -> Any:
610
+ """List messages in the dispatch queue."""
611
+ count_res = await db.execute(select(func.count(Message.id)))
612
+ total = count_res.scalar_one() or 0
613
+
614
+ stmt = (
615
+ select(Message)
616
+ .order_by(Message.created_at.desc())
617
+ .offset(skip)
618
+ .limit(limit)
619
+ )
620
+ result = await db.execute(stmt)
621
+ messages = result.scalars().all()
622
+
623
+ return wrap_data({
624
+ "items": [
625
+ {
626
+ "id": str(m.id),
627
+ "conversation_id": str(m.conversation_id),
628
+ "direction": m.direction,
629
+ "platform": m.platform,
630
+ "delivery_status": m.delivery_status,
631
+ "attempt_count": m.attempt_count,
632
+ "last_error": m.last_error,
633
+ "created_at": m.created_at.isoformat(),
634
+ }
635
+ for m in messages
636
+ ],
637
+ "total": total,
638
+ })
639
+
640
+
641
+ @router.patch("/dispatch/{message_id}/retry", response_model=ResponseEnvelope[dict])
642
+ async def retry_dispatch(
643
+ message_id: str,
644
+ db: AsyncSession = Depends(get_db),
645
+ admin_user: User = Depends(require_superadmin),
646
+ ) -> Any:
647
+ """Reset a failed message for retry."""
648
+ msg = await db.get(Message, UUID(message_id))
649
+ if not msg:
650
+ return wrap_error("Message not found")
651
+
652
+ msg.delivery_status = DeliveryStatus.PENDING
653
+ msg.attempt_count = 0
654
+ msg.last_error = None
655
+ await db.commit()
656
+
657
+ return wrap_data({"message": "Message reset for retry", "id": str(msg.id)})
658
+
659
+
660
+ @router.patch("/dispatch/{message_id}/dead-letter", response_model=ResponseEnvelope[dict])
661
+ async def dead_letter_dispatch(
662
+ message_id: str,
663
+ db: AsyncSession = Depends(get_db),
664
+ admin_user: User = Depends(require_superadmin),
665
+ ) -> Any:
666
+ """Move a message to dead-letter (mark as failed permanently)."""
667
+ msg = await db.get(Message, UUID(message_id))
668
+ if not msg:
669
+ return wrap_error("Message not found")
670
+
671
+ msg.delivery_status = DeliveryStatus.FAILED
672
+ msg.last_error = "Moved to dead-letter by admin"
673
+ await db.commit()
674
+
675
+ return wrap_data({"message": "Message moved to dead-letter", "id": str(msg.id)})
676
+
677
+
678
+ # ─── Automations Endpoints ───────────────────────────────────────────────────
679
+
680
+ @router.get("/automations", response_model=ResponseEnvelope[dict])
681
+ async def list_automations(
682
+ db: AsyncSession = Depends(get_db),
683
+ admin_user: User = Depends(require_superadmin),
684
+ skip: int = Query(0, ge=0),
685
+ limit: int = Query(50, ge=1, le=100),
686
+ ) -> Any:
687
+ """List all automation flows across all workspaces."""
688
+ count_res = await db.execute(select(func.count(Flow.id)))
689
+ total = count_res.scalar_one() or 0
690
+
691
+ stmt = select(Flow).order_by(Flow.created_at.desc()).offset(skip).limit(limit)
692
+ result = await db.execute(stmt)
693
+ flows = result.scalars().all()
694
+
695
+ return wrap_data({
696
+ "items": [
697
+ {
698
+ "id": str(f.id),
699
+ "name": f.name,
700
+ "workspace_id": str(f.workspace_id),
701
+ "status": f.status,
702
+ "description": f.description,
703
+ "created_at": f.created_at.isoformat(),
704
+ }
705
+ for f in flows
706
+ ],
707
+ "total": total,
708
+ })
709
+
710
+
711
+ @router.patch("/automations/{flow_id}/disable", response_model=ResponseEnvelope[dict])
712
+ async def disable_flow(
713
+ flow_id: str,
714
+ db: AsyncSession = Depends(get_db),
715
+ admin_user: User = Depends(require_superadmin),
716
+ ) -> Any:
717
+ """Disable (set to draft) an automation flow."""
718
+ flow = await db.get(Flow, UUID(flow_id))
719
+ if not flow:
720
+ return wrap_error("Flow not found")
721
+
722
+ flow.status = FlowStatus.DRAFT
723
+ await db.commit()
724
+
725
+ await log_admin_action(
726
+ db=db,
727
+ actor_user_id=admin_user.id,
728
+ action="flow_disable",
729
+ entity_type="flow",
730
+ entity_id=flow_id,
731
+ metadata={"workspace_id": str(flow.workspace_id)},
732
+ )
733
+
734
+ return wrap_data({"message": "Flow disabled", "id": str(flow.id)})
735
+
736
+
737
+ # ─── Prompt Configs Endpoint ─────────────────────────────────────────────────
738
+
739
+ @router.get("/prompt-configs", response_model=ResponseEnvelope[dict])
740
+ async def list_prompt_configs(
741
+ db: AsyncSession = Depends(get_db),
742
+ admin_user: User = Depends(require_superadmin),
743
+ skip: int = Query(0, ge=0),
744
+ limit: int = Query(50, ge=1, le=100),
745
+ ) -> Any:
746
+ """List all prompt configs across all workspaces."""
747
+ count_res = await db.execute(select(func.count(PromptConfig.id)))
748
+ total = count_res.scalar_one() or 0
749
+
750
+ stmt = select(PromptConfig).order_by(PromptConfig.created_at.desc()).offset(skip).limit(limit)
751
+ result = await db.execute(stmt)
752
+ configs = result.scalars().all()
753
+
754
+ return wrap_data({
755
+ "items": [
756
+ {
757
+ "id": str(c.id),
758
+ "name": c.name,
759
+ "workspace_id": str(c.workspace_id),
760
+ "current_version_id": str(c.current_version_id) if c.current_version_id else None,
761
+ "created_at": c.created_at.isoformat(),
762
+ }
763
+ for c in configs
764
+ ],
765
+ "total": total,
766
+ })
767
+
768
+
769
+ # ─── Zoho Health Endpoint ────────────────────────────────────────────────────
770
+
771
+ @router.get("/zoho-health", response_model=ResponseEnvelope[dict])
772
+ async def get_zoho_health(
773
+ db: AsyncSession = Depends(get_db),
774
+ admin_user: User = Depends(require_superadmin),
775
+ ) -> Any:
776
+ """Return Zoho integration health across all workspaces."""
777
+ result = await db.execute(
778
+ select(Integration).where(Integration.provider == "zoho")
779
+ )
780
+ integrations = result.scalars().all()
781
+
782
+ return wrap_data({
783
+ "items": [
784
+ {
785
+ "id": str(i.id),
786
+ "workspace_id": str(i.workspace_id),
787
+ "status": i.status,
788
+ "provider_workspace_id": i.provider_workspace_id,
789
+ "connected_at": i.connected_at.isoformat() if i.connected_at else None,
790
+ "last_checked_at": i.last_checked_at.isoformat() if i.last_checked_at else None,
791
+ "last_error": i.last_error,
792
+ }
793
+ for i in integrations
794
+ ],
795
+ })
796
+
797
+
798
+ # ─── Monitoring Endpoints ────────────────────────────────────────────────────
799
+
800
+ @router.get("/integrations", response_model=ResponseEnvelope[dict])
801
+ async def list_all_integrations(
802
+ db: AsyncSession = Depends(get_db),
803
+ admin_user: User = Depends(require_superadmin),
804
+ ) -> Any:
805
+ """List all integrations across all workspaces (monitoring)."""
806
+ result = await db.execute(
807
+ select(Integration).order_by(Integration.created_at.desc())
808
+ )
809
+ integrations = result.scalars().all()
810
+
811
+ return wrap_data({
812
+ "items": [
813
+ {
814
+ "id": str(i.id),
815
+ "workspace_id": str(i.workspace_id),
816
+ "provider": i.provider,
817
+ "status": i.status,
818
+ "provider_workspace_id": i.provider_workspace_id,
819
+ "connected_at": i.connected_at.isoformat() if i.connected_at else None,
820
+ "last_error": i.last_error,
821
+ }
822
+ for i in integrations
823
+ ],
824
+ })
825
+
826
+
827
+ @router.get("/executions", response_model=ResponseEnvelope[dict])
828
+ async def list_executions(
829
+ db: AsyncSession = Depends(get_db),
830
+ admin_user: User = Depends(require_superadmin),
831
+ ) -> Any:
832
+ """List recent execution instances across all workspaces (monitoring)."""
833
+ stmt = (
834
+ select(ExecutionInstance)
835
+ .order_by(ExecutionInstance.created_at.desc())
836
+ .limit(100)
837
+ )
838
+ result = await db.execute(stmt)
839
+ executions = result.scalars().all()
840
+
841
+ return wrap_data({
842
+ "items": [
843
+ {
844
+ "id": str(e.id),
845
+ "workspace_id": str(e.workspace_id),
846
+ "flow_version_id": str(e.flow_version_id),
847
+ "status": e.status,
848
+ "created_at": e.created_at.isoformat(),
849
+ }
850
+ for e in executions
851
+ ],
852
+ })
backend/app/api/v1/admin_auth.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import timedelta
2
+ from typing import Any
3
+ from fastapi import APIRouter, Depends, HTTPException
4
+ from pydantic import BaseModel
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+ from sqlmodel import select
7
+
8
+ from app.core import security
9
+ from app.core.config import settings
10
+ from app.core.db import get_db
11
+ from app.models.models import User
12
+ from app.schemas.envelope import ResponseEnvelope, wrap_data, wrap_error
13
+ from app.api.deps import get_current_user
14
+
15
+ router = APIRouter()
16
+
17
+
18
+ class AdminLoginRequest(BaseModel):
19
+ email: str
20
+ password: str
21
+
22
+
23
+ @router.post("/login", response_model=ResponseEnvelope[dict])
24
+ async def admin_login(
25
+ body: AdminLoginRequest,
26
+ db: AsyncSession = Depends(get_db),
27
+ ) -> Any:
28
+ """Admin login — JSON body {email, password}. Returns JWT if user is superadmin."""
29
+ result = await db.execute(select(User).where(User.email == body.email))
30
+ user = result.scalars().first()
31
+
32
+ if not user or not user.hashed_password:
33
+ return wrap_error("Invalid email or password")
34
+
35
+ if not security.verify_password(body.password, user.hashed_password):
36
+ return wrap_error("Invalid email or password")
37
+
38
+ if not user.is_active:
39
+ return wrap_error("Account is disabled")
40
+
41
+ if not user.is_superuser:
42
+ return wrap_error("Admin privileges required")
43
+
44
+ access_token = security.create_access_token(
45
+ user.id, expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
46
+ )
47
+
48
+ return wrap_data({"access_token": access_token, "token_type": "bearer"})
49
+
50
+
51
+ @router.get("/me", response_model=ResponseEnvelope[dict])
52
+ async def admin_me(
53
+ current_user: User = Depends(get_current_user),
54
+ ) -> Any:
55
+ """Return the authenticated admin user info."""
56
+ if not current_user.is_superuser:
57
+ raise HTTPException(status_code=403, detail="Admin privileges required")
58
+
59
+ return wrap_data({
60
+ "id": str(current_user.id),
61
+ "email": current_user.email,
62
+ "full_name": current_user.full_name,
63
+ "is_superuser": current_user.is_superuser,
64
+ })
backend/app/api/v1/auth.py CHANGED
@@ -179,7 +179,7 @@ async def verify_email(
179
  result = await db.execute(
180
  select(EmailVerificationToken).where(
181
  EmailVerificationToken.token_hash == token_hash,
182
- EmailVerificationToken.used_at == None,
183
  )
184
  )
185
  db_token = result.scalars().first()
@@ -310,7 +310,7 @@ async def forgot_password(
310
  await redis_client.expire(rl_key, 3600)
311
 
312
  if attempts > 5:
313
- return wrap_error("Rate limit exceeded for password resets", code="EMAIL_429")
314
  except Exception:
315
  # If redis fails, do we bypass rate limiting? Yes, degrades gracefully.
316
  pass
@@ -383,13 +383,13 @@ async def reset_password(
383
  .where(
384
  PasswordResetToken.token_hash == token_hash,
385
  PasswordResetToken.expires_at > datetime.now(timezone.utc),
386
- PasswordResetToken.used_at == None
387
  )
388
  )
389
  reset_token = result.scalars().first()
390
 
391
  if not reset_token:
392
- return wrap_error("Invalid or expired reset token", code="AUTH_INVALID_TOKEN")
393
 
394
  # Find user
395
  user = await db.get(User, reset_token.user_id)
 
179
  result = await db.execute(
180
  select(EmailVerificationToken).where(
181
  EmailVerificationToken.token_hash == token_hash,
182
+ EmailVerificationToken.used_at.is_(None),
183
  )
184
  )
185
  db_token = result.scalars().first()
 
310
  await redis_client.expire(rl_key, 3600)
311
 
312
  if attempts > 5:
313
+ return wrap_error("Rate limit exceeded for password resets")
314
  except Exception:
315
  # If redis fails, do we bypass rate limiting? Yes, degrades gracefully.
316
  pass
 
383
  .where(
384
  PasswordResetToken.token_hash == token_hash,
385
  PasswordResetToken.expires_at > datetime.now(timezone.utc),
386
+ PasswordResetToken.used_at.is_(None)
387
  )
388
  )
389
  reset_token = result.scalars().first()
390
 
391
  if not reset_token:
392
+ return wrap_error("Invalid or expired reset token")
393
 
394
  # Find user
395
  user = await db.get(User, reset_token.user_id)
backend/app/api/v1/integrations.py CHANGED
@@ -45,20 +45,20 @@ async def connect_integration(
45
 
46
  # 1. Validation for provider-specific fields
47
  if "access_token" not in connect_in.config:
48
- return wrap_error("Missing access_token in config", code="INTEGRATION_VALIDATION")
49
-
50
  # WhatsApp standard: provider_workspace_id = phone_number_id
51
  if provider == "whatsapp" and not connect_in.provider_workspace_id:
52
- return wrap_error("phone_number_id is required for WhatsApp", code="INTEGRATION_VALIDATION")
53
-
54
  # Meta standard: provider_workspace_id = page_id
55
  if provider == "meta" and not connect_in.provider_workspace_id:
56
- return wrap_error("page_id is required for Meta", code="INTEGRATION_VALIDATION")
57
-
58
  # Zoho standard: provider_workspace_id = org_id (optional but recommended)
59
  # We enforce NOT NULL for any CONNECTED status below
60
  if not connect_in.provider_workspace_id:
61
- return wrap_error(f"Identifier (provider_workspace_id) is required for {provider} connection", code="INTEGRATION_VALIDATION")
62
 
63
  # 2. Encrypt the config
64
  encrypted_config = encrypt_data(connect_in.config)
@@ -92,8 +92,7 @@ async def connect_integration(
92
  except IntegrityError:
93
  await db.rollback()
94
  return wrap_error(
95
- f"This connection ({connect_in.provider_workspace_id}) is already linked to another workspace.",
96
- code="INTEGRATION_409"
97
  )
98
 
99
  return wrap_data(integration)
 
45
 
46
  # 1. Validation for provider-specific fields
47
  if "access_token" not in connect_in.config:
48
+ return wrap_error("Missing access_token in config")
49
+
50
  # WhatsApp standard: provider_workspace_id = phone_number_id
51
  if provider == "whatsapp" and not connect_in.provider_workspace_id:
52
+ return wrap_error("phone_number_id is required for WhatsApp")
53
+
54
  # Meta standard: provider_workspace_id = page_id
55
  if provider == "meta" and not connect_in.provider_workspace_id:
56
+ return wrap_error("page_id is required for Meta")
57
+
58
  # Zoho standard: provider_workspace_id = org_id (optional but recommended)
59
  # We enforce NOT NULL for any CONNECTED status below
60
  if not connect_in.provider_workspace_id:
61
+ return wrap_error(f"Identifier (provider_workspace_id) is required for {provider} connection")
62
 
63
  # 2. Encrypt the config
64
  encrypted_config = encrypt_data(connect_in.config)
 
92
  except IntegrityError:
93
  await db.rollback()
94
  return wrap_error(
95
+ f"This connection ({connect_in.provider_workspace_id}) is already linked to another workspace."
 
96
  )
97
 
98
  return wrap_data(integration)
backend/main.py CHANGED
@@ -10,6 +10,7 @@ from app.api.v1.dispatch import router as dispatch_router
10
  from app.api.v1.inbox import router as inbox_router
11
  from app.api.v1.zoho import router as zoho_router
12
  from app.api.v1.admin import router as admin_router
 
13
  from app.api.v1.diagnostics import router as diagnostics_router
14
  from fastapi import HTTPException
15
  import uuid
@@ -59,6 +60,7 @@ async def add_process_time_header(request: Request, call_next):
59
  app.include_router(health.router, prefix=f"{settings.API_V1_STR}", tags=["health"])
60
  app.include_router(auth.router, prefix=f"{settings.API_V1_STR}/auth", tags=["auth"])
61
  app.include_router(admin_router, prefix=f"{settings.API_V1_STR}/admin", tags=["admin"])
 
62
 
63
  logger = logging.getLogger("api")
64
 
 
10
  from app.api.v1.inbox import router as inbox_router
11
  from app.api.v1.zoho import router as zoho_router
12
  from app.api.v1.admin import router as admin_router
13
+ from app.api.v1.admin_auth import router as admin_auth_router
14
  from app.api.v1.diagnostics import router as diagnostics_router
15
  from fastapi import HTTPException
16
  import uuid
 
60
  app.include_router(health.router, prefix=f"{settings.API_V1_STR}", tags=["health"])
61
  app.include_router(auth.router, prefix=f"{settings.API_V1_STR}/auth", tags=["auth"])
62
  app.include_router(admin_router, prefix=f"{settings.API_V1_STR}/admin", tags=["admin"])
63
+ app.include_router(admin_auth_router, prefix=f"{settings.API_V1_STR}/admin_auth", tags=["admin-auth"])
64
 
65
  logger = logging.getLogger("api")
66
 
backend/scripts/create_test_user.py CHANGED
@@ -28,49 +28,45 @@ async def create_test_user():
28
  await conn.run_sync(SQLModel.metadata.create_all)
29
 
30
  async with async_session() as session:
31
- # Check if user exists
32
  email = "test@example.com"
33
  result = await session.execute(select(User).where(User.email == email))
34
  user = result.scalars().first()
35
-
36
  if user:
37
  logger.info(f"User {email} already exists.")
38
- return
39
-
40
- # Create user
41
- logger.info(f"Creating test user: {email}")
42
- db_user = User(
43
- email=email,
44
- hashed_password=security.get_password_hash("Password123!"),
45
- full_name="Test User",
46
- is_active=True,
47
- is_superuser=True
48
- )
49
- session.add(db_user)
50
- await session.flush()
51
-
52
- # Create workspace
53
- logger.info("Creating workspace for test user...")
54
- db_workspace = Workspace(name="Test Workspace")
55
- session.add(db_workspace)
56
- await session.flush()
57
-
58
- # Create membership
59
- logger.info("Creating workspace membership...")
60
- db_membership = WorkspaceMember(
61
- user_id=db_user.id,
62
- workspace_id=db_workspace.id,
63
- role=WorkspaceRole.OWNER
64
- )
65
- session.add(db_membership)
66
-
67
- # --- Mission 8: Zoho Integration Setup ---
68
- from app.models.models import Integration, ZohoLeadMapping
69
-
70
- logger.info("Setting up Zoho Integration for test user...")
71
- # Check if exists
72
- integ = await session.execute(select(Integration).where(Integration.workspace_id == db_workspace.id, Integration.provider == "zoho"))
73
- if not integ.scalars().first():
74
  db_integration = Integration(
75
  workspace_id=db_workspace.id,
76
  provider="zoho",
@@ -78,10 +74,7 @@ async def create_test_user():
78
  encrypted_config='{"refresh_token": "mock_refresh", "client_id": "mock_client", "client_secret": "mock_secret", "access_token": "mock_access", "expires_at": "2099-01-01T00:00:00"}'
79
  )
80
  session.add(db_integration)
81
-
82
- # Check mapping
83
- mapping = await session.execute(select(ZohoLeadMapping).where(ZohoLeadMapping.workspace_id == db_workspace.id))
84
- if not mapping.scalars().first():
85
  db_mapping = ZohoLeadMapping(
86
  workspace_id=db_workspace.id,
87
  dedupe_strategy="EMAIL_OR_PHONE",
@@ -96,8 +89,41 @@ async def create_test_user():
96
  )
97
  session.add(db_mapping)
98
 
99
- await session.commit()
100
- logger.info("Test user and Zoho configuration created successfully.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
  if __name__ == "__main__":
103
  asyncio.run(create_test_user())
 
28
  await conn.run_sync(SQLModel.metadata.create_all)
29
 
30
  async with async_session() as session:
31
+ # Check if test user exists
32
  email = "test@example.com"
33
  result = await session.execute(select(User).where(User.email == email))
34
  user = result.scalars().first()
35
+
36
  if user:
37
  logger.info(f"User {email} already exists.")
38
+ else:
39
+ # Create user
40
+ logger.info(f"Creating test user: {email}")
41
+ db_user = User(
42
+ email=email,
43
+ hashed_password=security.get_password_hash("Password123!"),
44
+ full_name="Test User",
45
+ is_active=True,
46
+ is_superuser=True
47
+ )
48
+ session.add(db_user)
49
+ await session.flush()
50
+
51
+ # Create workspace
52
+ logger.info("Creating workspace for test user...")
53
+ db_workspace = Workspace(name="Test Workspace")
54
+ session.add(db_workspace)
55
+ await session.flush()
56
+
57
+ # Create membership
58
+ logger.info("Creating workspace membership...")
59
+ db_membership = WorkspaceMember(
60
+ user_id=db_user.id,
61
+ workspace_id=db_workspace.id,
62
+ role=WorkspaceRole.OWNER
63
+ )
64
+ session.add(db_membership)
65
+
66
+ # --- Mission 8: Zoho Integration Setup ---
67
+ from app.models.models import Integration, ZohoLeadMapping
68
+
69
+ logger.info("Setting up Zoho Integration for test user...")
 
 
 
 
70
  db_integration = Integration(
71
  workspace_id=db_workspace.id,
72
  provider="zoho",
 
74
  encrypted_config='{"refresh_token": "mock_refresh", "client_id": "mock_client", "client_secret": "mock_secret", "access_token": "mock_access", "expires_at": "2099-01-01T00:00:00"}'
75
  )
76
  session.add(db_integration)
77
+
 
 
 
78
  db_mapping = ZohoLeadMapping(
79
  workspace_id=db_workspace.id,
80
  dedupe_strategy="EMAIL_OR_PHONE",
 
89
  )
90
  session.add(db_mapping)
91
 
92
+ await session.commit()
93
+ logger.info("Test user and Zoho configuration created successfully.")
94
+
95
+ # --- Seed documented admin user (admin@leadpilot.io) ---
96
+ async with async_session() as session:
97
+ admin_email = "admin@leadpilot.io"
98
+ result = await session.execute(select(User).where(User.email == admin_email))
99
+ admin_user = result.scalars().first()
100
+
101
+ if admin_user:
102
+ logger.info(f"Admin user {admin_email} already exists.")
103
+ else:
104
+ logger.info(f"Creating admin user: {admin_email}")
105
+ db_admin = User(
106
+ email=admin_email,
107
+ hashed_password=security.get_password_hash("LeadPilot@password123"),
108
+ full_name="LeadPilot Admin",
109
+ is_active=True,
110
+ is_superuser=True,
111
+ )
112
+ session.add(db_admin)
113
+ await session.flush()
114
+
115
+ db_admin_ws = Workspace(name="Admin Workspace")
116
+ session.add(db_admin_ws)
117
+ await session.flush()
118
+
119
+ db_admin_member = WorkspaceMember(
120
+ user_id=db_admin.id,
121
+ workspace_id=db_admin_ws.id,
122
+ role=WorkspaceRole.OWNER,
123
+ )
124
+ session.add(db_admin_member)
125
+ await session.commit()
126
+ logger.info(f"Admin user {admin_email} created successfully.")
127
 
128
  if __name__ == "__main__":
129
  asyncio.run(create_test_user())
backend/scripts/seed_admin.py DELETED
@@ -1,37 +0,0 @@
1
- import asyncio
2
- import sys
3
- from sqlmodel import select
4
-
5
- from app.core.db import SessionLocal
6
- from app.models.admin_models import AdminUser
7
- from app.core.security import get_password_hash
8
-
9
- async def seed_initial_admin(email: str, password: str, name: str):
10
- async with SessionLocal() as db:
11
- # Check if any admin exists
12
- result = await db.execute(select(AdminUser))
13
- if result.scalars().first():
14
- print("Admin users already exist. Skipping seed.")
15
- return
16
-
17
- admin = AdminUser(
18
- email=email,
19
- full_name=name,
20
- hashed_password=get_password_hash(password),
21
- is_superuser=True,
22
- is_active=True
23
- )
24
- db.add(admin)
25
- await db.commit()
26
- print(f"Initial SuperAdmin created: {email}")
27
-
28
- if __name__ == "__main__":
29
- if len(sys.argv) < 4:
30
- print("Usage: python seed_admin.py <email> <password> <name>")
31
- sys.exit(1)
32
-
33
- email = sys.argv[1]
34
- password = sys.argv[2]
35
- name = sys.argv[3]
36
-
37
- asyncio.run(seed_initial_admin(email, password, name))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/scripts/seed_rbac.py DELETED
@@ -1,75 +0,0 @@
1
- import asyncio
2
- from sqlmodel import select
3
-
4
- from app.core.db import SessionLocal
5
- from app.models.admin_models import AdminRole, AdminPermission, AdminRolePermission
6
-
7
- DEFAULT_PERMISSIONS = [
8
- {"key": "users.read", "description": "View product users"},
9
- {"key": "users.write", "description": "Manage product users (toggle active)"},
10
- {"key": "workspaces.read", "description": "View workspaces"},
11
- {"key": "workspaces.write", "description": "Manage workspaces"},
12
- {"key": "modules.read", "description": "View module status"},
13
- {"key": "modules.write", "description": "Toggle system modules"},
14
- {"key": "audit.read", "description": "View admin audit logs"},
15
- {"key": "monitoring.read", "description": "View integrations, webhooks, and execution logs"},
16
- ]
17
-
18
- DEFAULT_ROLES = [
19
- {
20
- "name": "SuperAdmin",
21
- "description": "Full system access (bypasses permission checks)",
22
- "permissions": [] # is_superuser handles this
23
- },
24
- {
25
- "name": "Support",
26
- "description": "Standard support access",
27
- "permissions": ["users.read", "workspaces.read", "monitoring.read"]
28
- },
29
- {
30
- "name": "Admin",
31
- "description": "Administrative access with management capabilities",
32
- "permissions": ["users.read", "users.write", "workspaces.read", "workspaces.write", "modules.read", "monitoring.read", "audit.read"]
33
- }
34
- ]
35
-
36
- async def seed_rbac():
37
- async with SessionLocal() as db:
38
- # 1. Seed Permissions
39
- perm_map = {}
40
- for p_data in DEFAULT_PERMISSIONS:
41
- result = await db.execute(select(AdminPermission).where(AdminPermission.key == p_data["key"]))
42
- perm = result.scalars().first()
43
- if not perm:
44
- perm = AdminPermission(**p_data)
45
- db.add(perm)
46
- await db.flush()
47
- perm_map[p_data["key"]] = perm.id
48
-
49
- # 2. Seed Roles
50
- for r_data in DEFAULT_ROLES:
51
- result = await db.execute(select(AdminRole).where(AdminRole.name == r_data["name"]))
52
- role = result.scalars().first()
53
- if not role:
54
- role = AdminRole(name=r_data["name"], description=r_data["description"])
55
- db.add(role)
56
- await db.flush()
57
-
58
- # 3. Link Permissions
59
- for p_key in r_data["permissions"]:
60
- p_id = perm_map[p_key]
61
- link_result = await db.execute(
62
- select(AdminRolePermission).where(
63
- AdminRolePermission.role_id == role.id,
64
- AdminRolePermission.permission_id == p_id
65
- )
66
- )
67
- if not link_result.scalars().first():
68
- link = AdminRolePermission(role_id=role.id, permission_id=p_id)
69
- db.add(link)
70
-
71
- await db.commit()
72
- print("Admin RBAC seeded successfully.")
73
-
74
- if __name__ == "__main__":
75
- asyncio.run(seed_rbac())