AuthorBot Cursor commited on
Commit
6e276e0
·
1 Parent(s): cf196a9

Enforce strict SuperAdmin vs Author panel separation.

Browse files

Block SuperAdmin from /api/admin and /admin UI; validate author_slug matches JWT user; redirect each role to the correct panel.

Co-authored-by: Cursor <cursoragent@cursor.com>

app/admin/router.py CHANGED
@@ -25,7 +25,7 @@ from fastapi import APIRouter, Depends, File, Query, UploadFile
25
  from sqlalchemy import Integer
26
  from sqlalchemy.ext.asyncio import AsyncSession
27
 
28
- from app.dependencies import get_db, get_current_user, get_redis
29
  from app.repositories.book_repo import BookRepository
30
  from app.repositories.audit_repo import AuditRepository
31
 
@@ -35,7 +35,7 @@ router = APIRouter()
35
  @router.get("/{author_slug}/dashboard")
36
  async def dashboard(
37
  author_slug: str,
38
- current_user=Depends(get_current_user),
39
  db: AsyncSession = Depends(get_db),
40
  ):
41
  """Return high-level dashboard stats for the author."""
@@ -63,7 +63,7 @@ async def list_sessions(
63
  author_slug: str,
64
  limit: int = Query(50, ge=1, le=200),
65
  offset: int = 0,
66
- current_user=Depends(get_current_user),
67
  db: AsyncSession = Depends(get_db),
68
  ):
69
  """List reader sessions with pagination."""
@@ -102,7 +102,7 @@ async def search_sessions(
102
  q: str = Query("", min_length=0, max_length=200),
103
  page: int = Query(1, ge=1),
104
  per_page: int = Query(20, ge=1, le=100),
105
- current_user=Depends(get_current_user),
106
  db: AsyncSession = Depends(get_db),
107
  ):
108
  """Full-text search across chat messages for the author."""
@@ -164,7 +164,7 @@ async def search_sessions(
164
  async def block_session(
165
  author_slug: str,
166
  session_id: str,
167
- current_user=Depends(get_current_user),
168
  db: AsyncSession = Depends(get_db),
169
  ):
170
  """Block a reader session (403 on next message)."""
@@ -183,7 +183,7 @@ async def block_session(
183
  async def unblock_session(
184
  author_slug: str,
185
  session_id: str,
186
- current_user=Depends(get_current_user),
187
  db: AsyncSession = Depends(get_db),
188
  ):
189
  """Unblock a reader session."""
@@ -201,7 +201,7 @@ async def unblock_session(
201
  @router.get("/{author_slug}/books")
202
  async def list_books(
203
  author_slug: str,
204
- current_user=Depends(get_current_user),
205
  db: AsyncSession = Depends(get_db),
206
  ):
207
  """List all books for the author."""
@@ -230,7 +230,7 @@ async def list_books(
230
  async def delete_book(
231
  author_slug: str,
232
  book_id: str,
233
- current_user=Depends(get_current_user),
234
  db: AsyncSession = Depends(get_db),
235
  ):
236
  """Delete a book and its ChromaDB collection."""
@@ -252,7 +252,7 @@ async def delete_book(
252
  async def upload_book_cover(
253
  author_slug: str,
254
  book_id: str,
255
- current_user=Depends(get_current_user),
256
  db: AsyncSession = Depends(get_db),
257
  file: UploadFile = File(...),
258
  ):
@@ -333,7 +333,7 @@ async def upload_book_cover(
333
  async def get_analytics(
334
  author_slug: str,
335
  days: int = Query(30, ge=1, le=365),
336
- current_user=Depends(get_current_user),
337
  db: AsyncSession = Depends(get_db),
338
  ):
339
  """Return analytics data for dashboard charts."""
@@ -362,7 +362,7 @@ async def get_analytics(
362
  async def get_conversion_funnel(
363
  author_slug: str,
364
  days: int = Query(30, ge=1, le=365),
365
- current_user=Depends(get_current_user),
366
  db: AsyncSession = Depends(get_db),
367
  ):
368
  """Return conversion funnel data derived from analytics events."""
@@ -430,7 +430,7 @@ async def get_conversion_funnel(
430
  async def get_intent_distribution(
431
  author_slug: str,
432
  days: int = Query(30, ge=1, le=365),
433
- current_user=Depends(get_current_user),
434
  db: AsyncSession = Depends(get_db),
435
  ):
436
  """Return distribution of detected intents."""
@@ -462,7 +462,7 @@ async def get_intent_distribution(
462
  async def change_password(
463
  author_slug: str,
464
  body: dict,
465
- current_user=Depends(get_current_user),
466
  db: AsyncSession = Depends(get_db),
467
  ):
468
  """Self-service password change."""
@@ -493,7 +493,7 @@ async def change_password(
493
  @router.get("/{author_slug}/widget-config")
494
  async def get_widget_config(
495
  author_slug: str,
496
- current_user=Depends(get_current_user),
497
  ):
498
  """Return current widget configuration."""
499
  return {
@@ -510,7 +510,7 @@ async def get_widget_config(
510
  async def update_widget_config(
511
  author_slug: str,
512
  body: dict,
513
- current_user=Depends(get_current_user),
514
  db: AsyncSession = Depends(get_db),
515
  ):
516
  """Update widget configuration."""
@@ -539,7 +539,7 @@ async def update_widget_config(
539
  @router.get("/{author_slug}/profile")
540
  async def get_profile(
541
  author_slug: str,
542
- current_user=Depends(get_current_user),
543
  ):
544
  """Return current author profile."""
545
  return {
@@ -555,7 +555,7 @@ async def get_profile(
555
  async def update_profile(
556
  author_slug: str,
557
  body: dict,
558
- current_user=Depends(get_current_user),
559
  db: AsyncSession = Depends(get_db),
560
  ):
561
  """Update author profile."""
@@ -577,7 +577,7 @@ async def update_profile(
577
  @router.get("/{author_slug}/personality")
578
  async def get_personality(
579
  author_slug: str,
580
- current_user=Depends(get_current_user),
581
  ):
582
  """Return bot personality settings."""
583
  return {
@@ -592,7 +592,7 @@ async def get_personality(
592
  async def update_personality(
593
  author_slug: str,
594
  body: dict,
595
- current_user=Depends(get_current_user),
596
  db: AsyncSession = Depends(get_db),
597
  ):
598
  """Update bot personality settings."""
@@ -617,7 +617,7 @@ async def update_personality(
617
  @router.get("/{author_slug}/notifications")
618
  async def get_notifications(
619
  author_slug: str,
620
- current_user=Depends(get_current_user),
621
  ):
622
  """Return notification preferences."""
623
  return {
@@ -632,7 +632,7 @@ async def get_notifications(
632
  async def update_notifications(
633
  author_slug: str,
634
  body: dict,
635
- current_user=Depends(get_current_user),
636
  db: AsyncSession = Depends(get_db),
637
  ):
638
  """Update notification preferences."""
@@ -655,7 +655,7 @@ async def update_notifications(
655
  @router.get("/{author_slug}/embed-token")
656
  async def get_embed_token(
657
  author_slug: str,
658
- current_user=Depends(get_current_user),
659
  db: AsyncSession = Depends(get_db),
660
  ):
661
  """Return the active subscription token for embedding the widget.
@@ -718,7 +718,7 @@ async def update_smart_link(
718
  author_slug: str,
719
  book_id: str,
720
  body: dict,
721
- current_user=Depends(get_current_user),
722
  db: AsyncSession = Depends(get_db),
723
  ):
724
  """Update buy/preview URLs for a book."""
@@ -753,7 +753,7 @@ async def update_smart_link(
753
  @router.get("/{author_slug}/token-usage")
754
  async def get_token_usage(
755
  author_slug: str,
756
- current_user=Depends(get_current_user),
757
  db: AsyncSession = Depends(get_db),
758
  ):
759
  """Return token budget and consumption for the active subscription only."""
@@ -776,7 +776,7 @@ async def get_transcript(
776
  session_id: str,
777
  page: int = Query(1, ge=1),
778
  per_page: int = Query(50, ge=1, le=200),
779
- current_user=Depends(get_current_user),
780
  db: AsyncSession = Depends(get_db),
781
  ):
782
  """Return paginated message transcript for a session."""
@@ -861,7 +861,7 @@ async def annotate_message(
861
  session_id: str,
862
  message_id: str,
863
  body: dict,
864
- current_user=Depends(get_current_user),
865
  db: AsyncSession = Depends(get_db),
866
  ):
867
  """Add admin annotation to a chat message (append-only)."""
@@ -899,7 +899,7 @@ async def flag_message(
899
  session_id: str,
900
  message_id: str,
901
  body: dict,
902
- current_user=Depends(get_current_user),
903
  db: AsyncSession = Depends(get_db),
904
  ):
905
  """Flag a message as spam, quality issue, or escalation."""
@@ -940,7 +940,7 @@ async def flag_message(
940
  async def list_qa(
941
  author_slug: str,
942
  book_id: str | None = None,
943
- current_user=Depends(get_current_user),
944
  db: AsyncSession = Depends(get_db),
945
  ):
946
  """List all custom Q&A pairs for the author."""
@@ -981,7 +981,7 @@ async def list_qa(
981
  async def create_qa(
982
  author_slug: str,
983
  body: dict,
984
- current_user=Depends(get_current_user),
985
  db: AsyncSession = Depends(get_db),
986
  ):
987
  """Create a new custom Q&A pair."""
@@ -1022,7 +1022,7 @@ async def create_qa(
1022
  @router.post("/{author_slug}/qa/import")
1023
  async def import_qa_csv(
1024
  author_slug: str,
1025
- current_user=Depends(get_current_user),
1026
  db: AsyncSession = Depends(get_db),
1027
  file: UploadFile = File(...),
1028
  ):
@@ -1085,7 +1085,7 @@ async def import_qa_csv(
1085
  @router.get("/{author_slug}/qa/export")
1086
  async def export_qa_csv(
1087
  author_slug: str,
1088
- current_user=Depends(get_current_user),
1089
  db: AsyncSession = Depends(get_db),
1090
  ):
1091
  """Export all Q&A pairs as CSV."""
@@ -1121,7 +1121,7 @@ async def update_qa(
1121
  author_slug: str,
1122
  qa_id: str,
1123
  body: dict,
1124
- current_user=Depends(get_current_user),
1125
  db: AsyncSession = Depends(get_db),
1126
  ):
1127
  """Update an existing Q&A pair."""
@@ -1168,7 +1168,7 @@ async def update_qa(
1168
  async def delete_qa(
1169
  author_slug: str,
1170
  qa_id: str,
1171
- current_user=Depends(get_current_user),
1172
  db: AsyncSession = Depends(get_db),
1173
  ):
1174
  """Delete a Q&A pair."""
@@ -1200,7 +1200,7 @@ async def delete_qa(
1200
  async def export_sessions(
1201
  author_slug: str,
1202
  days: int = Query(30, ge=1, le=365),
1203
- current_user=Depends(get_current_user),
1204
  db: AsyncSession = Depends(get_db),
1205
  ):
1206
  """Export chat sessions as CSV."""
@@ -1248,7 +1248,7 @@ async def export_sessions(
1248
  async def export_analytics(
1249
  author_slug: str,
1250
  days: int = Query(30, ge=1, le=365),
1251
- current_user=Depends(get_current_user),
1252
  db: AsyncSession = Depends(get_db),
1253
  ):
1254
  """Export daily analytics as CSV."""
@@ -1293,7 +1293,7 @@ async def export_conversations(
1293
  author_slug: str,
1294
  session_id: str | None = None,
1295
  days: int = Query(7, ge=1, le=30),
1296
- current_user=Depends(get_current_user),
1297
  db: AsyncSession = Depends(get_db),
1298
  ):
1299
  """Export full conversation transcripts as CSV."""
 
25
  from sqlalchemy import Integer
26
  from sqlalchemy.ext.asyncio import AsyncSession
27
 
28
+ from app.dependencies import get_db, get_current_author_scoped, get_redis
29
  from app.repositories.book_repo import BookRepository
30
  from app.repositories.audit_repo import AuditRepository
31
 
 
35
  @router.get("/{author_slug}/dashboard")
36
  async def dashboard(
37
  author_slug: str,
38
+ current_user=Depends(get_current_author_scoped),
39
  db: AsyncSession = Depends(get_db),
40
  ):
41
  """Return high-level dashboard stats for the author."""
 
63
  author_slug: str,
64
  limit: int = Query(50, ge=1, le=200),
65
  offset: int = 0,
66
+ current_user=Depends(get_current_author_scoped),
67
  db: AsyncSession = Depends(get_db),
68
  ):
69
  """List reader sessions with pagination."""
 
102
  q: str = Query("", min_length=0, max_length=200),
103
  page: int = Query(1, ge=1),
104
  per_page: int = Query(20, ge=1, le=100),
105
+ current_user=Depends(get_current_author_scoped),
106
  db: AsyncSession = Depends(get_db),
107
  ):
108
  """Full-text search across chat messages for the author."""
 
164
  async def block_session(
165
  author_slug: str,
166
  session_id: str,
167
+ current_user=Depends(get_current_author_scoped),
168
  db: AsyncSession = Depends(get_db),
169
  ):
170
  """Block a reader session (403 on next message)."""
 
183
  async def unblock_session(
184
  author_slug: str,
185
  session_id: str,
186
+ current_user=Depends(get_current_author_scoped),
187
  db: AsyncSession = Depends(get_db),
188
  ):
189
  """Unblock a reader session."""
 
201
  @router.get("/{author_slug}/books")
202
  async def list_books(
203
  author_slug: str,
204
+ current_user=Depends(get_current_author_scoped),
205
  db: AsyncSession = Depends(get_db),
206
  ):
207
  """List all books for the author."""
 
230
  async def delete_book(
231
  author_slug: str,
232
  book_id: str,
233
+ current_user=Depends(get_current_author_scoped),
234
  db: AsyncSession = Depends(get_db),
235
  ):
236
  """Delete a book and its ChromaDB collection."""
 
252
  async def upload_book_cover(
253
  author_slug: str,
254
  book_id: str,
255
+ current_user=Depends(get_current_author_scoped),
256
  db: AsyncSession = Depends(get_db),
257
  file: UploadFile = File(...),
258
  ):
 
333
  async def get_analytics(
334
  author_slug: str,
335
  days: int = Query(30, ge=1, le=365),
336
+ current_user=Depends(get_current_author_scoped),
337
  db: AsyncSession = Depends(get_db),
338
  ):
339
  """Return analytics data for dashboard charts."""
 
362
  async def get_conversion_funnel(
363
  author_slug: str,
364
  days: int = Query(30, ge=1, le=365),
365
+ current_user=Depends(get_current_author_scoped),
366
  db: AsyncSession = Depends(get_db),
367
  ):
368
  """Return conversion funnel data derived from analytics events."""
 
430
  async def get_intent_distribution(
431
  author_slug: str,
432
  days: int = Query(30, ge=1, le=365),
433
+ current_user=Depends(get_current_author_scoped),
434
  db: AsyncSession = Depends(get_db),
435
  ):
436
  """Return distribution of detected intents."""
 
462
  async def change_password(
463
  author_slug: str,
464
  body: dict,
465
+ current_user=Depends(get_current_author_scoped),
466
  db: AsyncSession = Depends(get_db),
467
  ):
468
  """Self-service password change."""
 
493
  @router.get("/{author_slug}/widget-config")
494
  async def get_widget_config(
495
  author_slug: str,
496
+ current_user=Depends(get_current_author_scoped),
497
  ):
498
  """Return current widget configuration."""
499
  return {
 
510
  async def update_widget_config(
511
  author_slug: str,
512
  body: dict,
513
+ current_user=Depends(get_current_author_scoped),
514
  db: AsyncSession = Depends(get_db),
515
  ):
516
  """Update widget configuration."""
 
539
  @router.get("/{author_slug}/profile")
540
  async def get_profile(
541
  author_slug: str,
542
+ current_user=Depends(get_current_author_scoped),
543
  ):
544
  """Return current author profile."""
545
  return {
 
555
  async def update_profile(
556
  author_slug: str,
557
  body: dict,
558
+ current_user=Depends(get_current_author_scoped),
559
  db: AsyncSession = Depends(get_db),
560
  ):
561
  """Update author profile."""
 
577
  @router.get("/{author_slug}/personality")
578
  async def get_personality(
579
  author_slug: str,
580
+ current_user=Depends(get_current_author_scoped),
581
  ):
582
  """Return bot personality settings."""
583
  return {
 
592
  async def update_personality(
593
  author_slug: str,
594
  body: dict,
595
+ current_user=Depends(get_current_author_scoped),
596
  db: AsyncSession = Depends(get_db),
597
  ):
598
  """Update bot personality settings."""
 
617
  @router.get("/{author_slug}/notifications")
618
  async def get_notifications(
619
  author_slug: str,
620
+ current_user=Depends(get_current_author_scoped),
621
  ):
622
  """Return notification preferences."""
623
  return {
 
632
  async def update_notifications(
633
  author_slug: str,
634
  body: dict,
635
+ current_user=Depends(get_current_author_scoped),
636
  db: AsyncSession = Depends(get_db),
637
  ):
638
  """Update notification preferences."""
 
655
  @router.get("/{author_slug}/embed-token")
656
  async def get_embed_token(
657
  author_slug: str,
658
+ current_user=Depends(get_current_author_scoped),
659
  db: AsyncSession = Depends(get_db),
660
  ):
661
  """Return the active subscription token for embedding the widget.
 
718
  author_slug: str,
719
  book_id: str,
720
  body: dict,
721
+ current_user=Depends(get_current_author_scoped),
722
  db: AsyncSession = Depends(get_db),
723
  ):
724
  """Update buy/preview URLs for a book."""
 
753
  @router.get("/{author_slug}/token-usage")
754
  async def get_token_usage(
755
  author_slug: str,
756
+ current_user=Depends(get_current_author_scoped),
757
  db: AsyncSession = Depends(get_db),
758
  ):
759
  """Return token budget and consumption for the active subscription only."""
 
776
  session_id: str,
777
  page: int = Query(1, ge=1),
778
  per_page: int = Query(50, ge=1, le=200),
779
+ current_user=Depends(get_current_author_scoped),
780
  db: AsyncSession = Depends(get_db),
781
  ):
782
  """Return paginated message transcript for a session."""
 
861
  session_id: str,
862
  message_id: str,
863
  body: dict,
864
+ current_user=Depends(get_current_author_scoped),
865
  db: AsyncSession = Depends(get_db),
866
  ):
867
  """Add admin annotation to a chat message (append-only)."""
 
899
  session_id: str,
900
  message_id: str,
901
  body: dict,
902
+ current_user=Depends(get_current_author_scoped),
903
  db: AsyncSession = Depends(get_db),
904
  ):
905
  """Flag a message as spam, quality issue, or escalation."""
 
940
  async def list_qa(
941
  author_slug: str,
942
  book_id: str | None = None,
943
+ current_user=Depends(get_current_author_scoped),
944
  db: AsyncSession = Depends(get_db),
945
  ):
946
  """List all custom Q&A pairs for the author."""
 
981
  async def create_qa(
982
  author_slug: str,
983
  body: dict,
984
+ current_user=Depends(get_current_author_scoped),
985
  db: AsyncSession = Depends(get_db),
986
  ):
987
  """Create a new custom Q&A pair."""
 
1022
  @router.post("/{author_slug}/qa/import")
1023
  async def import_qa_csv(
1024
  author_slug: str,
1025
+ current_user=Depends(get_current_author_scoped),
1026
  db: AsyncSession = Depends(get_db),
1027
  file: UploadFile = File(...),
1028
  ):
 
1085
  @router.get("/{author_slug}/qa/export")
1086
  async def export_qa_csv(
1087
  author_slug: str,
1088
+ current_user=Depends(get_current_author_scoped),
1089
  db: AsyncSession = Depends(get_db),
1090
  ):
1091
  """Export all Q&A pairs as CSV."""
 
1121
  author_slug: str,
1122
  qa_id: str,
1123
  body: dict,
1124
+ current_user=Depends(get_current_author_scoped),
1125
  db: AsyncSession = Depends(get_db),
1126
  ):
1127
  """Update an existing Q&A pair."""
 
1168
  async def delete_qa(
1169
  author_slug: str,
1170
  qa_id: str,
1171
+ current_user=Depends(get_current_author_scoped),
1172
  db: AsyncSession = Depends(get_db),
1173
  ):
1174
  """Delete a Q&A pair."""
 
1200
  async def export_sessions(
1201
  author_slug: str,
1202
  days: int = Query(30, ge=1, le=365),
1203
+ current_user=Depends(get_current_author_scoped),
1204
  db: AsyncSession = Depends(get_db),
1205
  ):
1206
  """Export chat sessions as CSV."""
 
1248
  async def export_analytics(
1249
  author_slug: str,
1250
  days: int = Query(30, ge=1, le=365),
1251
+ current_user=Depends(get_current_author_scoped),
1252
  db: AsyncSession = Depends(get_db),
1253
  ):
1254
  """Export daily analytics as CSV."""
 
1293
  author_slug: str,
1294
  session_id: str | None = None,
1295
  days: int = Query(7, ge=1, le=30),
1296
+ current_user=Depends(get_current_author_scoped),
1297
  db: AsyncSession = Depends(get_db),
1298
  ):
1299
  """Export full conversation transcripts as CSV."""
app/admin/templates/admin.html CHANGED
@@ -854,11 +854,29 @@ function closeModal() { document.getElementById('modal-overlay').classList.remov
854
  document.getElementById('modal-overlay').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal(); });
855
 
856
  /* ── AUTH ── */
857
- auth.restoreSession().then(function (ok) {
 
 
 
 
 
 
 
 
 
 
 
 
 
858
  if (ok) {
 
859
  token = auth.getAccessToken();
860
  authorSlug = auth.getAuthorSlug();
861
  showDashboard();
 
 
 
 
862
  }
863
  });
864
 
@@ -872,6 +890,13 @@ document.getElementById('login-btn').onclick = async () => {
872
  document.getElementById('email').value,
873
  document.getElementById('password').value
874
  );
 
 
 
 
 
 
 
875
  token = auth.getAccessToken();
876
  authorSlug = data.author_slug || auth.getAuthorSlug() || 'me';
877
  if (authorSlug) localStorage.setItem('author_slug', authorSlug);
 
854
  document.getElementById('modal-overlay').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal(); });
855
 
856
  /* ── AUTH ── */
857
+ function clearSuperAdminSessionKeys() {
858
+ localStorage.removeItem('sa_token');
859
+ localStorage.removeItem('sa_refresh_token');
860
+ }
861
+
862
+ function clearAuthorSessionKeys() {
863
+ localStorage.removeItem('access_token');
864
+ localStorage.removeItem('refresh_token');
865
+ localStorage.removeItem('author_slug');
866
+ }
867
+
868
+ auth.restoreSession(function (user) {
869
+ return user && user.role === 'author';
870
+ }).then(function (ok) {
871
  if (ok) {
872
+ clearSuperAdminSessionKeys();
873
  token = auth.getAccessToken();
874
  authorSlug = auth.getAuthorSlug();
875
  showDashboard();
876
+ } else if (auth.getAccessToken()) {
877
+ auth.clearSession();
878
+ token = null;
879
+ authorSlug = null;
880
  }
881
  });
882
 
 
890
  document.getElementById('email').value,
891
  document.getElementById('password').value
892
  );
893
+ if (data.is_superadmin || data.role === 'superadmin') {
894
+ auth.clearSession();
895
+ clearSuperAdminSessionKeys();
896
+ window.location.href = '/superadmin';
897
+ return;
898
+ }
899
+ clearSuperAdminSessionKeys();
900
  token = auth.getAccessToken();
901
  authorSlug = data.author_slug || auth.getAuthorSlug() || 'me';
902
  if (authorSlug) localStorage.setItem('author_slug', authorSlug);
app/api/ingest.py CHANGED
@@ -18,7 +18,7 @@ from fastapi import APIRouter, Depends, File, UploadFile, HTTPException
18
  from fastapi.responses import StreamingResponse
19
  from sqlalchemy.ext.asyncio import AsyncSession
20
 
21
- from app.dependencies import get_db, get_current_user, get_redis
22
  from app.repositories.book_repo import BookRepository
23
  from app.repositories.document_repo import DocumentRepository
24
  from app.config import get_settings
@@ -35,7 +35,7 @@ async def upload_book(
35
  genre: str = "",
36
  price: float = 0.0,
37
  buy_url: str = "",
38
- current_user=Depends(get_current_user),
39
  db: AsyncSession = Depends(get_db),
40
  redis=Depends(get_redis),
41
  ):
@@ -91,7 +91,7 @@ async def upload_book(
91
  async def ingestion_progress(
92
  author_slug: str,
93
  book_id: str,
94
- current_user=Depends(get_current_user),
95
  redis=Depends(get_redis),
96
  ):
97
  """SSE stream for real-time ingestion progress."""
 
18
  from fastapi.responses import StreamingResponse
19
  from sqlalchemy.ext.asyncio import AsyncSession
20
 
21
+ from app.dependencies import get_db, get_current_author_scoped, get_redis
22
  from app.repositories.book_repo import BookRepository
23
  from app.repositories.document_repo import DocumentRepository
24
  from app.config import get_settings
 
35
  genre: str = "",
36
  price: float = 0.0,
37
  buy_url: str = "",
38
+ current_user=Depends(get_current_author_scoped),
39
  db: AsyncSession = Depends(get_db),
40
  redis=Depends(get_redis),
41
  ):
 
91
  async def ingestion_progress(
92
  author_slug: str,
93
  book_id: str,
94
+ current_user=Depends(get_current_author_scoped),
95
  redis=Depends(get_redis),
96
  ):
97
  """SSE stream for real-time ingestion progress."""
app/dependencies.py CHANGED
@@ -106,9 +106,9 @@ async def get_redis() -> aioredis.Redis:
106
 
107
  # ─── Authentication ───────────────────────────────────────────────────────────
108
 
109
- async def get_current_user(
110
- authorization: str = Header(default=""),
111
- db: AsyncSession = Depends(get_db),
112
  ):
113
  """Validate JWT and return the authenticated user model."""
114
  from app.core.access.token_crypto import decode_jwt
@@ -143,6 +143,38 @@ async def get_current_user(
143
  return user
144
 
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  async def get_current_superadmin(current_user=Depends(get_current_user)):
147
  """Ensure the authenticated user is a SuperAdmin."""
148
  if current_user.role != "superadmin":
 
106
 
107
  # ─── Authentication ───────────────────────────────────────────────────────────
108
 
109
+ async def _get_authenticated_user(
110
+ authorization: str,
111
+ db: AsyncSession,
112
  ):
113
  """Validate JWT and return the authenticated user model."""
114
  from app.core.access.token_crypto import decode_jwt
 
143
  return user
144
 
145
 
146
+ async def get_current_user(
147
+ authorization: str = Header(default=""),
148
+ db: AsyncSession = Depends(get_db),
149
+ ):
150
+ """Validate JWT and return any authenticated user (author or superadmin)."""
151
+ return await _get_authenticated_user(authorization, db)
152
+
153
+
154
+ async def get_current_author(
155
+ authorization: str = Header(default=""),
156
+ db: AsyncSession = Depends(get_db),
157
+ ):
158
+ """Ensure the authenticated user is an Author (not SuperAdmin)."""
159
+ user = await _get_authenticated_user(authorization, db)
160
+ if user.role != "author":
161
+ raise HTTPException(
162
+ status_code=403,
163
+ detail="Author access required. SuperAdmins must use /superadmin.",
164
+ )
165
+ return user
166
+
167
+
168
+ async def get_current_author_scoped(
169
+ author_slug: str,
170
+ current_user=Depends(get_current_author),
171
+ ):
172
+ """Ensure the author can only access their own tenant data."""
173
+ if author_slug != current_user.id:
174
+ raise HTTPException(status_code=403, detail="Access denied to this author account")
175
+ return current_user
176
+
177
+
178
  async def get_current_superadmin(current_user=Depends(get_current_user)):
179
  """Ensure the authenticated user is a SuperAdmin."""
180
  if current_user.role != "superadmin":
app/schemas/auth.py CHANGED
@@ -44,6 +44,7 @@ class TokenResponse(BaseModel):
44
  token_type: str = "bearer"
45
  author_slug: str | None = None
46
  is_superadmin: bool = False
 
47
 
48
 
49
  class UserResponse(BaseModel):
 
44
  token_type: str = "bearer"
45
  author_slug: str | None = None
46
  is_superadmin: bool = False
47
+ role: str | None = None
48
 
49
 
50
  class UserResponse(BaseModel):
app/services/auth_service.py CHANGED
@@ -75,6 +75,7 @@ class AuthService:
75
  return {
76
  **tokens,
77
  "is_superadmin": False,
 
78
  "author_slug": user.id,
79
  }
80
 
@@ -128,6 +129,7 @@ class AuthService:
128
  return {
129
  **tokens,
130
  "is_superadmin": user.role == "superadmin",
 
131
  "author_slug": getattr(user, "slug", None) or user.id,
132
  }
133
 
@@ -158,6 +160,7 @@ class AuthService:
158
  return {
159
  **tokens,
160
  "is_superadmin": user.role == "superadmin",
 
161
  "author_slug": user.id,
162
  }
163
 
 
75
  return {
76
  **tokens,
77
  "is_superadmin": False,
78
+ "role": "author",
79
  "author_slug": user.id,
80
  }
81
 
 
129
  return {
130
  **tokens,
131
  "is_superadmin": user.role == "superadmin",
132
+ "role": user.role,
133
  "author_slug": getattr(user, "slug", None) or user.id,
134
  }
135
 
 
160
  return {
161
  **tokens,
162
  "is_superadmin": user.role == "superadmin",
163
+ "role": user.role,
164
  "author_slug": user.id,
165
  }
166
 
app/superadmin/templates/superadmin.html CHANGED
@@ -451,8 +451,15 @@ function closeModal() { document.getElementById('modal-overlay').classList.remov
451
  document.getElementById('modal-overlay').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal(); });
452
 
453
  /* AUTH */
 
 
 
 
 
 
454
  auth.restoreSession(function (user) { return user.role === 'superadmin'; }).then(function (ok) {
455
  if (ok) {
 
456
  token = auth.getAccessToken();
457
  showDashboard();
458
  } else if (auth.getAccessToken()) {
@@ -472,7 +479,12 @@ document.getElementById('sa-login-btn').onclick = async () => {
472
  document.getElementById('sa-user').value,
473
  document.getElementById('sa-pass').value
474
  );
475
- if (!data.is_superadmin) { auth.clearSession(); throw new Error('Not a SuperAdmin account'); }
 
 
 
 
 
476
  token = auth.getAccessToken();
477
  toast('Welcome, SuperAdmin', 'success');
478
  showDashboard();
 
451
  document.getElementById('modal-overlay').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal(); });
452
 
453
  /* AUTH */
454
+ function clearAuthorSessionKeys() {
455
+ localStorage.removeItem('access_token');
456
+ localStorage.removeItem('refresh_token');
457
+ localStorage.removeItem('author_slug');
458
+ }
459
+
460
  auth.restoreSession(function (user) { return user.role === 'superadmin'; }).then(function (ok) {
461
  if (ok) {
462
+ clearAuthorSessionKeys();
463
  token = auth.getAccessToken();
464
  showDashboard();
465
  } else if (auth.getAccessToken()) {
 
479
  document.getElementById('sa-user').value,
480
  document.getElementById('sa-pass').value
481
  );
482
+ if (!data.is_superadmin) {
483
+ auth.clearSession();
484
+ clearAuthorSessionKeys();
485
+ throw new Error('Not a SuperAdmin account — use /admin for author login');
486
+ }
487
+ clearAuthorSessionKeys();
488
  token = auth.getAccessToken();
489
  toast('Welcome, SuperAdmin', 'success');
490
  showDashboard();
tests/unit/test_suite_e_role_access.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Suite E — Role separation (SuperAdmin vs Author)."""
2
+
3
+ import pytest
4
+ from fastapi import HTTPException
5
+
6
+ from app.dependencies import (
7
+ get_current_author,
8
+ get_current_author_scoped,
9
+ get_current_superadmin,
10
+ )
11
+ from app.models.user import User
12
+
13
+
14
+ def _make_user(role: str, user_id: str = "author-1") -> User:
15
+ return User(
16
+ id=user_id,
17
+ email="test@example.com",
18
+ password_hash="x",
19
+ role=role,
20
+ is_active=True,
21
+ )
22
+
23
+
24
+ @pytest.mark.asyncio
25
+ async def test_get_current_author_rejects_superadmin(monkeypatch):
26
+ user = _make_user("superadmin")
27
+
28
+ async def fake_auth(authorization, db):
29
+ return user
30
+
31
+ monkeypatch.setattr("app.dependencies._get_authenticated_user", fake_auth)
32
+
33
+ with pytest.raises(HTTPException) as exc:
34
+ await get_current_author(authorization="Bearer x", db=None) # type: ignore[arg-type]
35
+ assert exc.value.status_code == 403
36
+ assert "superadmin" in exc.value.detail.lower()
37
+
38
+
39
+ @pytest.mark.asyncio
40
+ async def test_get_current_author_accepts_author(monkeypatch):
41
+ user = _make_user("author")
42
+
43
+ async def fake_auth(authorization, db):
44
+ return user
45
+
46
+ monkeypatch.setattr("app.dependencies._get_authenticated_user", fake_auth)
47
+
48
+ result = await get_current_author(authorization="Bearer x", db=None) # type: ignore[arg-type]
49
+ assert result.role == "author"
50
+
51
+
52
+ @pytest.mark.asyncio
53
+ async def test_author_slug_must_match_user_id():
54
+ user = _make_user("author", "author-abc")
55
+ with pytest.raises(HTTPException) as exc:
56
+ await get_current_author_scoped(author_slug="other-author", current_user=user)
57
+ assert exc.value.status_code == 403
58
+
59
+
60
+ @pytest.mark.asyncio
61
+ async def test_superadmin_dependency_rejects_author():
62
+ user = _make_user("author")
63
+ with pytest.raises(HTTPException) as exc:
64
+ await get_current_superadmin(current_user=user)
65
+ assert exc.value.status_code == 403
66
+
67
+
68
+ @pytest.mark.asyncio
69
+ async def test_superadmin_dependency_accepts_superadmin():
70
+ user = _make_user("superadmin")
71
+ result = await get_current_superadmin(current_user=user)
72
+ assert result.role == "superadmin"