devaanand commited on
Commit
b633038
·
1 Parent(s): 284c53a

Admin page v1: user roles (lab tier), password resets, failed-run requeue, verification queue

Browse files

Synced from lab mainline. /admin gated by ADMIN_EMAILS env allowlist.
Lab-role accounts get unlimited saved runs (replaces the global env
hack). 72 tests passing.

.env.example CHANGED
@@ -51,6 +51,10 @@
51
  # Public base URL of this app (Google redirect target).
52
  # CCR_APP_URL=http://127.0.0.1:8000
53
 
 
 
 
 
 
54
  # ---- Phase 2 (not read yet; reserved names) ----
55
  # DATABASE_URL=postgresql://...
56
- # ADMIN_EMAILS=devaanand@umass.edu,matari@umass.edu
 
51
  # Public base URL of this app (Google redirect target).
52
  # CCR_APP_URL=http://127.0.0.1:8000
53
 
54
+ # ---- Admin access ----
55
+ # Comma-separated allowlist; these signed-in accounts see /admin (user roles,
56
+ # password resets, failed-run requeue, verification queue, usage stats).
57
+ # ADMIN_EMAILS=devaanand@umass.edu,matari@umass.edu
58
+
59
  # ---- Phase 2 (not read yet; reserved names) ----
60
  # DATABASE_URL=postgresql://...
 
MANUAL_TESTING.md CHANGED
@@ -162,3 +162,20 @@ Checklist before giving the URL to real users:
162
  - [ ] `CCR_DATA_DIR` on a persistent volume (default /tmp is ephemeral)
163
  - [ ] `CCR_ANON_TTL_HOURS=24` (default in the image)
164
  - [ ] Smoke test: sections 2, 4, 6, 7 above
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  - [ ] `CCR_DATA_DIR` on a persistent volume (default /tmp is ephemeral)
163
  - [ ] `CCR_ANON_TTL_HOURS=24` (default in the image)
164
  - [ ] Smoke test: sections 2, 4, 6, 7 above
165
+
166
+ ## 12. Admin page (/admin)
167
+
168
+ Requires `ADMIN_EMAILS` to include your signed-in email (see .env.example).
169
+
170
+ 1. Sign in with an allowlisted account: an "Admin" link appears in the header;
171
+ non-admins (and signed-out visitors) see an access notice at /admin.
172
+ 2. Overview: account/run/project counters plus scales awaiting verification.
173
+ 3. Users: toggle a user to "lab" (their saved-run cap disappears - check
174
+ /api/auth/me shows max_saved_runs null), reset a password (temporary
175
+ password shown once; old one stops working), delete a user (removes all
176
+ their data; self-deletion refused).
177
+ 4. Failed runs: a failed job lists with its error tail; Requeue re-runs it
178
+ (refused when the corpus file is already gone - anonymous retention).
179
+ 5. Verification: mark a scale Verified; its "unverified" flag disappears
180
+ platform-wide. Statuses are applied back to the library YAML before
181
+ production.
backend/app/admin.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Admin endpoints - the minimal operational surface (design: admin page v1).
2
+
3
+ Four concrete pains drive this, nothing speculative:
4
+ * password resets ("reset = admin action" finally has an admin action),
5
+ * per-user lab tier (replaces the global saved-run env hack: lab accounts
6
+ get unlimited saved runs, public accounts keep the cap),
7
+ * the RA's verification queue (mark constructs verified from the UI;
8
+ the YAML library stays the durable source of truth - DB status is the
9
+ operational overlay and is written back to YAML by the developer),
10
+ * usage numbers + failed-run triage (the PI's "how is testing going?"
11
+ answered with counts, and stuck runs requeued without SQL).
12
+
13
+ Access: signed-in AND email in the ADMIN_EMAILS env allowlist. Admin is an
14
+ env capability, not a DB role, so the DB cannot mint admins.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import secrets
21
+ from datetime import datetime, timedelta, timezone
22
+
23
+ from fastapi import APIRouter, Depends, HTTPException, Request
24
+ from sqlalchemy import func
25
+ from sqlalchemy.orm import Session
26
+
27
+ from . import auth, retention, storage
28
+ from . import jobs as jobs_module
29
+ from .db import get_db
30
+ from .models import Construct, Corpus, Job, Project, User
31
+
32
+ router = APIRouter(prefix="/api/admin", tags=["admin"])
33
+
34
+
35
+ def require_admin(request: Request) -> dict:
36
+ user = auth.get_current_user(request)
37
+ if user is None or not auth.is_admin(user.get("email")):
38
+ raise HTTPException(403, "Admin access required.")
39
+ return user
40
+
41
+
42
+ # ---------------------------------------------------------------- overview
43
+ @router.get("/overview")
44
+ def overview(db: Session = Depends(get_db), _admin: dict = Depends(require_admin)):
45
+ week_ago = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat(timespec="seconds")
46
+ runs_by_status = dict(
47
+ db.query(Job.status, func.count(Job.id)).group_by(Job.status).all()
48
+ )
49
+ return {
50
+ "users": db.query(User).count(),
51
+ "lab_users": db.query(User).filter_by(role="lab").count(),
52
+ "projects": db.query(Project).count(),
53
+ "anonymous_projects": db.query(Project).filter_by(owner_user_id="").count(),
54
+ "corpora": db.query(Corpus).count(),
55
+ "runs_total": db.query(Job).count(),
56
+ "runs_by_status": runs_by_status,
57
+ "runs_last_7_days": db.query(Job).filter(Job.created_at >= week_ago).count(),
58
+ "signups_last_7_days": db.query(User).filter(User.created_at >= week_ago).count(),
59
+ "constructs_unverified": db.query(Construct)
60
+ .filter(Construct.verification_status != "verified")
61
+ .filter_by(is_seed=True)
62
+ .count(),
63
+ }
64
+
65
+
66
+ # ------------------------------------------------------------------- users
67
+ @router.get("/users")
68
+ def list_users(db: Session = Depends(get_db), _admin: dict = Depends(require_admin)):
69
+ saved = dict(
70
+ db.query(Project.owner_user_id, func.count(Job.id))
71
+ .join(Job, Job.project_id == Project.id)
72
+ .filter(Project.owner_user_id != "")
73
+ .group_by(Project.owner_user_id)
74
+ .all()
75
+ )
76
+ return [
77
+ {
78
+ "id": u.id,
79
+ "email": u.email,
80
+ "name": u.name,
81
+ "role": u.role or "member",
82
+ "google_only": not u.password_hash,
83
+ "saved_runs": saved.get(u.id, 0),
84
+ "created_at": u.created_at,
85
+ "is_admin": auth.is_admin(u.email),
86
+ }
87
+ for u in db.query(User).order_by(User.created_at.desc()).all()
88
+ ]
89
+
90
+
91
+ @router.post("/users/{user_id}/role")
92
+ def set_role(
93
+ user_id: str,
94
+ body: dict,
95
+ db: Session = Depends(get_db),
96
+ _admin: dict = Depends(require_admin),
97
+ ):
98
+ role = str(body.get("role", "")).strip().lower()
99
+ if role not in ("member", "lab"):
100
+ raise HTTPException(400, "Role must be 'member' or 'lab'.")
101
+ user = db.get(User, user_id)
102
+ if user is None:
103
+ raise HTTPException(404, "User not found")
104
+ user.role = role
105
+ db.commit()
106
+ return {"id": user.id, "role": user.role}
107
+
108
+
109
+ @router.post("/users/{user_id}/reset-password")
110
+ def reset_password(
111
+ user_id: str, db: Session = Depends(get_db), _admin: dict = Depends(require_admin)
112
+ ):
113
+ """Generate a temporary password, shown ONCE in the response. The admin
114
+ passes it to the user, who should change it (or use Google sign-in)."""
115
+ user = db.get(User, user_id)
116
+ if user is None:
117
+ raise HTTPException(404, "User not found")
118
+ temp = secrets.token_urlsafe(9) # 12 chars, meets the minimum length
119
+ user.password_hash = auth.hash_password(temp)
120
+ db.commit()
121
+ return {"id": user.id, "email": user.email, "temporary_password": temp}
122
+
123
+
124
+ @router.delete("/users/{user_id}", status_code=204)
125
+ def delete_user(
126
+ user_id: str, db: Session = Depends(get_db), admin: dict = Depends(require_admin)
127
+ ):
128
+ """Remove an account and everything it owns (files included)."""
129
+ if user_id == admin["id"]:
130
+ raise HTTPException(400, "You cannot delete your own admin account.")
131
+ user = db.get(User, user_id)
132
+ if user is None:
133
+ raise HTTPException(404, "User not found")
134
+ for project in db.query(Project).filter_by(owner_user_id=user_id).all():
135
+ retention.delete_project_cascade(db, project)
136
+ db.delete(user)
137
+ db.commit()
138
+ return None
139
+
140
+
141
+ # ----------------------------------------------------------- failed runs
142
+ @router.get("/jobs/failed")
143
+ def failed_jobs(db: Session = Depends(get_db), _admin: dict = Depends(require_admin)):
144
+ rows = (
145
+ db.query(Job)
146
+ .filter(Job.status == "failed")
147
+ .order_by(Job.created_at.desc())
148
+ .limit(50)
149
+ .all()
150
+ )
151
+ out = []
152
+ for j in rows:
153
+ corpus = db.get(Corpus, j.corpus_id)
154
+ out.append(
155
+ {
156
+ "id": j.id,
157
+ "created_at": j.created_at,
158
+ "model_name": j.model_name,
159
+ "language": j.language,
160
+ "corpus_filename": corpus.filename if corpus else "",
161
+ "corpus_file_available": bool(corpus and storage.exists(corpus.path)),
162
+ "error_tail": (j.error or "").strip().splitlines()[-1] if j.error else "",
163
+ }
164
+ )
165
+ return out
166
+
167
+
168
+ @router.post("/jobs/{job_id}/requeue")
169
+ def requeue_job(
170
+ job_id: str, db: Session = Depends(get_db), _admin: dict = Depends(require_admin)
171
+ ):
172
+ job = db.get(Job, job_id)
173
+ if job is None:
174
+ raise HTTPException(404, "Job not found")
175
+ if job.status != "failed":
176
+ raise HTTPException(409, f"Only failed jobs can be requeued (status: {job.status}).")
177
+ corpus = db.get(Corpus, job.corpus_id)
178
+ if corpus is None or not storage.exists(corpus.path):
179
+ raise HTTPException(
180
+ 410, "The corpus file is gone (anonymous retention); the run cannot be repeated."
181
+ )
182
+ job.status = "queued"
183
+ job.error = ""
184
+ job.progress = 0.0
185
+ job.started_at = ""
186
+ job.finished_at = ""
187
+ db.commit()
188
+ jobs_module.submit_job(job.id)
189
+ return {"id": job.id, "status": "queued"}
190
+
191
+
192
+ # ------------------------------------------------------ verification queue
193
+ @router.get("/constructs")
194
+ def constructs_for_review(
195
+ status: str = "",
196
+ db: Session = Depends(get_db),
197
+ _admin: dict = Depends(require_admin),
198
+ ):
199
+ q = db.query(Construct).filter_by(is_seed=True)
200
+ if status:
201
+ q = q.filter(Construct.verification_status == status)
202
+ return [
203
+ {
204
+ "id": c.id,
205
+ "name": c.name,
206
+ "slug": c.construct_slug,
207
+ "category": c.category or "",
208
+ "n_items": len(json.loads(c.items_json)),
209
+ "verification_status": c.verification_status or "draft",
210
+ "reference": c.reference or "",
211
+ }
212
+ for c in q.order_by(Construct.name).all()
213
+ ]
214
+
215
+
216
+ @router.post("/constructs/{construct_id}/verification")
217
+ def set_verification(
218
+ construct_id: str,
219
+ body: dict,
220
+ db: Session = Depends(get_db),
221
+ _admin: dict = Depends(require_admin),
222
+ ):
223
+ """Operational overlay for the RA's workflow. The YAML library remains the
224
+ durable source of truth: statuses set here are exported and written back to
225
+ the library files by the developer before production (recorded decision)."""
226
+ status = str(body.get("status", "")).strip()
227
+ if status not in ("verified", "needs_verification"):
228
+ raise HTTPException(400, "Status must be 'verified' or 'needs_verification'.")
229
+ construct = db.get(Construct, construct_id)
230
+ if construct is None:
231
+ raise HTTPException(404, "Construct not found")
232
+ construct.verification_status = status
233
+ db.commit()
234
+ return {"id": construct.id, "verification_status": construct.verification_status}
backend/app/auth.py CHANGED
@@ -73,6 +73,17 @@ def cookies_secure() -> bool:
73
  return os.environ.get("CCR_COOKIE_SECURE") == "1"
74
 
75
 
 
 
 
 
 
 
 
 
 
 
 
76
  # ---------------------------------------------------------- passwords
77
  def hash_password(password: str) -> str:
78
  salt = secrets.token_bytes(16)
 
73
  return os.environ.get("CCR_COOKIE_SECURE") == "1"
74
 
75
 
76
+ def admin_emails() -> set[str]:
77
+ """Comma-separated allowlist; admin is an env-granted capability, not a DB
78
+ role, so a compromised database cannot mint admins."""
79
+ raw = os.environ.get("ADMIN_EMAILS", "")
80
+ return {e.strip().lower() for e in raw.split(",") if e.strip()}
81
+
82
+
83
+ def is_admin(email: str | None) -> bool:
84
+ return bool(email) and email.strip().lower() in admin_emails()
85
+
86
+
87
  # ---------------------------------------------------------- passwords
88
  def hash_password(password: str) -> str:
89
  salt = secrets.token_bytes(16)
backend/app/main.py CHANGED
@@ -22,6 +22,7 @@ from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
22
  from fastapi.staticfiles import StaticFiles
23
  from sqlalchemy.orm import Session
24
 
 
25
  from . import auth, auth_google, retention, storage
26
  from . import jobs as jobs_module
27
  from . import registry
@@ -84,6 +85,7 @@ async def lifespan(_: FastAPI):
84
 
85
 
86
  app = FastAPI(title="CCR Platform", version="0.1.0", lifespan=lifespan)
 
87
 
88
  app.add_middleware(GZipMiddleware, minimum_size=1024) # constructs payload + SPA compress ~4-5x
89
  app.add_middleware(
@@ -208,14 +210,19 @@ def auth_me(
208
  user: dict | None = Depends(auth.get_current_user),
209
  ):
210
  if user:
 
 
211
  return {
212
  "signed_in": True,
213
  "name": user["name"],
214
  "email": user["email"],
 
 
215
  "limits": {"max_bytes": MAX_UPLOAD_BYTES, "max_rows": None},
216
  "usage": {
217
  "saved_runs": _saved_runs_used(db, user["id"]),
218
- "max_saved_runs": auth.user_max_saved_runs(),
 
219
  },
220
  }
221
  return {
@@ -617,8 +624,12 @@ def create_job(
617
  "Sign in (top right) to keep running - accounts are free.",
618
  )
619
  else:
620
- # Signed-in tier: saved-run cap instead of deletion (their data, their call).
621
- if _saved_runs_used(db, user["id"]) >= auth.user_max_saved_runs():
 
 
 
 
622
  raise HTTPException(
623
  409,
624
  f"You have {auth.user_max_saved_runs()} saved runs (the maximum). "
@@ -766,6 +777,16 @@ def testing_guide():
766
  return FileResponse(GUIDE_HTML, media_type="text/html")
767
 
768
 
 
 
 
 
 
 
 
 
 
 
769
  if SAMPLES_DIR.exists():
770
  app.mount("/samples", StaticFiles(directory=SAMPLES_DIR), name="samples")
771
 
 
22
  from fastapi.staticfiles import StaticFiles
23
  from sqlalchemy.orm import Session
24
 
25
+ from . import admin as admin_module
26
  from . import auth, auth_google, retention, storage
27
  from . import jobs as jobs_module
28
  from . import registry
 
85
 
86
 
87
  app = FastAPI(title="CCR Platform", version="0.1.0", lifespan=lifespan)
88
+ app.include_router(admin_module.router)
89
 
90
  app.add_middleware(GZipMiddleware, minimum_size=1024) # constructs payload + SPA compress ~4-5x
91
  app.add_middleware(
 
210
  user: dict | None = Depends(auth.get_current_user),
211
  ):
212
  if user:
213
+ row = db.get(User, user["id"])
214
+ role = (row.role if row else None) or "member"
215
  return {
216
  "signed_in": True,
217
  "name": user["name"],
218
  "email": user["email"],
219
+ "role": role,
220
+ "is_admin": auth.is_admin(user["email"]),
221
  "limits": {"max_bytes": MAX_UPLOAD_BYTES, "max_rows": None},
222
  "usage": {
223
  "saved_runs": _saved_runs_used(db, user["id"]),
224
+ # lab accounts: unlimited saved runs (admin-granted role)
225
+ "max_saved_runs": None if role == "lab" else auth.user_max_saved_runs(),
226
  },
227
  }
228
  return {
 
624
  "Sign in (top right) to keep running - accounts are free.",
625
  )
626
  else:
627
+ # Signed-in tier: saved-run cap instead of deletion (their data, their
628
+ # call). Lab-role accounts (admin-granted) are uncapped.
629
+ row = db.get(User, user["id"])
630
+ if (row.role if row else "member") != "lab" and (
631
+ _saved_runs_used(db, user["id"]) >= auth.user_max_saved_runs()
632
+ ):
633
  raise HTTPException(
634
  409,
635
  f"You have {auth.user_max_saved_runs()} saved runs (the maximum). "
 
777
  return FileResponse(GUIDE_HTML, media_type="text/html")
778
 
779
 
780
+ @app.get("/admin", include_in_schema=False)
781
+ def admin_page():
782
+ """Serve the SPA at /admin; the frontend renders the admin view there
783
+ (and shows access-denied unless /api/auth/me says is_admin)."""
784
+ index = Path(__file__).resolve().parent.parent / "static" / "index.html"
785
+ if not index.exists():
786
+ raise HTTPException(404, "UI not built.")
787
+ return FileResponse(index, media_type="text/html")
788
+
789
+
790
  if SAMPLES_DIR.exists():
791
  app.mount("/samples", StaticFiles(directory=SAMPLES_DIR), name="samples")
792
 
backend/app/models.py CHANGED
@@ -29,6 +29,9 @@ class User(Base):
29
  email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
30
  name: Mapped[str] = mapped_column(String(120), default="")
31
  password_hash: Mapped[str] = mapped_column(Text) # scrypt$salt$digest (auth.py)
 
 
 
32
  created_at: Mapped[str] = mapped_column(String(32), default=_now)
33
 
34
 
 
29
  email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
30
  name: Mapped[str] = mapped_column(String(120), default="")
31
  password_hash: Mapped[str] = mapped_column(Text) # scrypt$salt$digest (auth.py)
32
+ role: Mapped[str] = mapped_column(String(16), default="member") # member | lab
33
+ # "lab" = unlimited saved runs (set via /admin). Admins are NOT a DB role:
34
+ # admin access is granted by the ADMIN_EMAILS env allowlist (auth.is_admin).
35
  created_at: Mapped[str] = mapped_column(String(32), default=_now)
36
 
37
 
backend/static/assets/index-CeDfDyqW.js ADDED
The diff for this file is too large to render. See raw diff
 
backend/static/assets/index-DrFcy6bH.js DELETED
The diff for this file is too large to render. See raw diff
 
backend/static/index.html CHANGED
@@ -4,7 +4,7 @@
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>CCR Platform - Contextualized Construct Representations</title>
7
- <script type="module" crossorigin src="/assets/index-DrFcy6bH.js"></script>
8
  <link rel="stylesheet" crossorigin href="/assets/index-CN_FzJfm.css">
9
  </head>
10
  <body>
 
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>CCR Platform - Contextualized Construct Representations</title>
7
+ <script type="module" crossorigin src="/assets/index-CeDfDyqW.js"></script>
8
  <link rel="stylesheet" crossorigin href="/assets/index-CN_FzJfm.css">
9
  </head>
10
  <body>
backend/tests/test_admin.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Admin surface: env-allowlist gate, roles + lab-tier cap bypass, password
2
+ reset, user deletion cascade, failed-run requeue, verification queue."""
3
+
4
+ import io
5
+ import time
6
+
7
+ import pytest
8
+ from fastapi.testclient import TestClient
9
+
10
+ from app.main import app
11
+
12
+ ADMIN_EMAIL = "admin@lab.test"
13
+
14
+
15
+ @pytest.fixture()
16
+ def client(monkeypatch):
17
+ monkeypatch.setenv("ADMIN_EMAILS", f"{ADMIN_EMAIL}, other-admin@lab.test")
18
+ with TestClient(app) as c:
19
+ yield c
20
+
21
+
22
+ def register(client, email, name="User"):
23
+ resp = client.post(
24
+ "/api/auth/register", json={"email": email, "password": "password123", "name": name}
25
+ )
26
+ assert resp.status_code == 201
27
+ return resp.json()
28
+
29
+
30
+ def sign_in_as(client, email, name="User"):
31
+ """Login-or-register: the test DB persists across tests in this module."""
32
+ client.post("/api/auth/logout")
33
+ resp = client.post("/api/auth/login", json={"email": email, "password": "password123"})
34
+ if resp.status_code != 200:
35
+ register(client, email, name)
36
+
37
+
38
+ def csv_rows(n: int) -> bytes:
39
+ return ("text\n" + "\n".join(f"sample sentence number {i} here" for i in range(n))).encode()
40
+
41
+
42
+ def upload(client, project_id, name, payload):
43
+ return client.post(
44
+ f"/api/projects/{project_id}/corpora",
45
+ files={"file": (name, io.BytesIO(payload), "text/csv")},
46
+ )
47
+
48
+
49
+ def run_job(client, project_id, corpus_id, construct_id):
50
+ return client.post(
51
+ "/api/jobs",
52
+ json={
53
+ "project_id": project_id,
54
+ "corpus_id": corpus_id,
55
+ "construct_id": construct_id,
56
+ "text_column": "text",
57
+ "model_name": "fake-deterministic",
58
+ },
59
+ )
60
+
61
+
62
+ def wait_for_job(client, job_id, timeout=10.0):
63
+ deadline = time.time() + timeout
64
+ while time.time() < deadline:
65
+ job = client.get(f"/api/jobs/{job_id}").json()
66
+ if job["status"] in ("completed", "failed"):
67
+ return job
68
+ time.sleep(0.05)
69
+ raise TimeoutError(job_id)
70
+
71
+
72
+ # ------------------------------------------------------------------ access
73
+ def test_admin_requires_allowlisted_signed_in_user(client):
74
+ assert client.get("/api/admin/overview").status_code == 403 # anonymous
75
+
76
+ register(client, "normal@lab.test")
77
+ assert client.get("/api/admin/overview").status_code == 403 # signed in, not allowlisted
78
+ me = client.get("/api/auth/me").json()
79
+ assert me["is_admin"] is False
80
+ client.post("/api/auth/logout")
81
+
82
+ register(client, ADMIN_EMAIL, "Admin")
83
+ me = client.get("/api/auth/me").json()
84
+ assert me["is_admin"] is True
85
+ resp = client.get("/api/admin/overview")
86
+ assert resp.status_code == 200
87
+ assert resp.json()["users"] >= 2
88
+
89
+
90
+ def test_admin_page_route_serves_ui(client):
91
+ resp = client.get("/admin")
92
+ assert resp.status_code == 200
93
+ assert "text/html" in resp.headers["content-type"]
94
+
95
+
96
+ # ------------------------------------------------------ roles and lab tier
97
+ def test_lab_role_bypasses_saved_run_cap(client, monkeypatch):
98
+ monkeypatch.setenv("CCR_USER_MAX_SAVED_RUNS", "1")
99
+ register(client, "phd@lab.test", "PhD")
100
+ project = client.post("/api/projects", json={"name": "LabTier"}).json()
101
+ corpus = upload(client, project["id"], "c.csv", csv_rows(5)).json()
102
+ construct = client.get("/api/constructs").json()[0]
103
+
104
+ resp = run_job(client, project["id"], corpus["id"], construct["id"])
105
+ assert resp.status_code == 201
106
+ wait_for_job(client, resp.json()["id"])
107
+ assert run_job(client, project["id"], corpus["id"], construct["id"]).status_code == 409
108
+
109
+ phd_id = next(
110
+ u["id"] for u in _as_admin(client).get("/api/admin/users").json()
111
+ if u["email"] == "phd@lab.test"
112
+ )
113
+ resp = client.post(f"/api/admin/users/{phd_id}/role", json={"role": "lab"})
114
+ assert resp.status_code == 200 and resp.json()["role"] == "lab"
115
+ client.post("/api/auth/logout")
116
+
117
+ client.post("/api/auth/login", json={"email": "phd@lab.test", "password": "password123"})
118
+ me = client.get("/api/auth/me").json()
119
+ assert me["role"] == "lab" and me["usage"]["max_saved_runs"] is None
120
+ resp = run_job(client, project["id"], corpus["id"], construct["id"])
121
+ assert resp.status_code == 201 # cap no longer applies
122
+
123
+
124
+ def _as_admin(client):
125
+ sign_in_as(client, ADMIN_EMAIL, "Admin")
126
+ return client
127
+
128
+
129
+ # --------------------------------------------------------- reset + delete
130
+ def test_password_reset_issues_working_temp_password(client):
131
+ register(client, "forgetful@lab.test")
132
+ sign_in_as(client, ADMIN_EMAIL, "Admin")
133
+
134
+ uid = next(
135
+ u["id"] for u in client.get("/api/admin/users").json()
136
+ if u["email"] == "forgetful@lab.test"
137
+ )
138
+ temp = client.post(f"/api/admin/users/{uid}/reset-password").json()["temporary_password"]
139
+ client.post("/api/auth/logout")
140
+
141
+ bad = client.post(
142
+ "/api/auth/login", json={"email": "forgetful@lab.test", "password": "password123"}
143
+ )
144
+ assert bad.status_code == 401 # old password dead
145
+ good = client.post(
146
+ "/api/auth/login", json={"email": "forgetful@lab.test", "password": temp}
147
+ )
148
+ assert good.status_code == 200
149
+
150
+
151
+ def test_delete_user_cascades_and_protects_self(client):
152
+ register(client, "doomed@lab.test")
153
+ project = client.post("/api/projects", json={"name": "DoomedData"}).json()
154
+ upload(client, project["id"], "c.csv", csv_rows(5))
155
+ client.post("/api/auth/logout")
156
+
157
+ sign_in_as(client, ADMIN_EMAIL, "Admin")
158
+ users = client.get("/api/admin/users").json()
159
+ doomed_id = next(u["id"] for u in users if u["email"] == "doomed@lab.test")
160
+ my_id = next(u["id"] for u in users if u["email"] == ADMIN_EMAIL)
161
+
162
+ assert client.delete(f"/api/admin/users/{my_id}").status_code == 400 # self-protect
163
+ assert client.delete(f"/api/admin/users/{doomed_id}").status_code == 204
164
+ assert all(
165
+ u["email"] != "doomed@lab.test" for u in client.get("/api/admin/users").json()
166
+ )
167
+ assert client.get(f"/api/projects/{project['id']}/corpora").status_code in (403, 404)
168
+
169
+
170
+ # ------------------------------------------------------------ failed runs
171
+ def test_failed_job_requeue(client, monkeypatch):
172
+ sign_in_as(client, ADMIN_EMAIL, "Admin")
173
+ project = client.post("/api/projects", json={"name": "FailThenFix"}).json()
174
+ corpus = upload(client, project["id"], "c.csv", csv_rows(5)).json()
175
+ construct = client.get("/api/constructs").json()[0]
176
+
177
+ # Force a failure: point the job at a column that exists in the DB row but
178
+ # sabotage the stored file? Simpler: monkeypatch the engine to raise once.
179
+ from app import jobs as jobs_module
180
+
181
+ original = jobs_module.run_ccr
182
+ calls = {"n": 0}
183
+
184
+ def flaky(*args, **kwargs):
185
+ calls["n"] += 1
186
+ if calls["n"] == 1:
187
+ raise RuntimeError("synthetic failure for admin requeue test")
188
+ return original(*args, **kwargs)
189
+
190
+ monkeypatch.setattr(jobs_module, "run_ccr", flaky)
191
+
192
+ job = run_job(client, project["id"], corpus["id"], construct["id"]).json()
193
+ assert wait_for_job(client, job["id"])["status"] == "failed"
194
+
195
+ failed = client.get("/api/admin/jobs/failed").json()
196
+ entry = next(f for f in failed if f["id"] == job["id"])
197
+ assert entry["corpus_file_available"] is True
198
+ assert "synthetic failure" in entry["error_tail"]
199
+
200
+ assert client.post(f"/api/admin/jobs/{job['id']}/requeue").status_code == 200
201
+ assert wait_for_job(client, job["id"])["status"] == "completed"
202
+
203
+
204
+ # ------------------------------------------------------------ verification
205
+ def test_verification_queue_marks_verified(client):
206
+ sign_in_as(client, ADMIN_EMAIL, "Admin")
207
+ queue = client.get("/api/admin/constructs?status=needs_verification").json()
208
+ assert len(queue) > 0
209
+ target = queue[0]
210
+
211
+ resp = client.post(
212
+ f"/api/admin/constructs/{target['id']}/verification", json={"status": "verified"}
213
+ )
214
+ assert resp.status_code == 200 and resp.json()["verification_status"] == "verified"
215
+
216
+ remaining = client.get("/api/admin/constructs?status=needs_verification").json()
217
+ assert all(c["id"] != target["id"] for c in remaining)
218
+ # visible to regular users too (flag disappears in the picker/details)
219
+ pub = next(c for c in client.get("/api/constructs").json() if c["id"] == target["id"])
220
+ assert pub["verification_status"] == "verified"
backend/tests/test_guide_and_samples.py DELETED
@@ -1,38 +0,0 @@
1
- """Tester-facing extras: the /guide page and the /samples demo-data mount.
2
-
3
- Both exist for the hosted dev instance so the PI/students can test without
4
- being handed files out of band. They are static content, but regressions here
5
- (a renamed sample file, a moved directory) would silently break the guide's
6
- download links, so pin the contract.
7
- """
8
-
9
- import re
10
- from pathlib import Path
11
-
12
- from fastapi.testclient import TestClient
13
-
14
- from app.main import SAMPLES_DIR, app
15
-
16
- client = TestClient(app)
17
-
18
-
19
- def test_guide_serves_html():
20
- r = client.get("/guide")
21
- assert r.status_code == 200
22
- assert "text/html" in r.headers["content-type"]
23
- assert "Testing Guide" in r.text
24
-
25
-
26
- def test_samples_mount_serves_files():
27
- r = client.get("/samples/sample_corpus.csv")
28
- assert r.status_code == 200
29
- assert r.text.splitlines()[0].startswith("id,")
30
-
31
-
32
- def test_every_sample_link_in_guide_resolves():
33
- """Every /samples/<file> href in guide.html must exist in sample_data/."""
34
- html = (Path(__file__).resolve().parents[1] / "app" / "guide.html").read_text()
35
- linked = set(re.findall(r'href="/samples/([^"]+)"', html))
36
- assert linked, "guide.html should link to sample files"
37
- missing = sorted(f for f in linked if not (SAMPLES_DIR / f).exists())
38
- assert not missing, f"guide links to samples that do not exist: {missing}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/AdminPage.jsx ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ // Minimal admin surface (v1): overview counters, user roles + password
4
+ // resets, failed-run requeue, and the RA's construct-verification queue.
5
+ // Access is enforced server-side (ADMIN_EMAILS); this page just renders
6
+ // what the admin API returns.
7
+
8
+ async function adminFetch(path, options = {}) {
9
+ const resp = await fetch(path, options);
10
+ if (!resp.ok) {
11
+ let detail = resp.statusText;
12
+ try {
13
+ detail = (await resp.json()).detail || detail;
14
+ } catch { /* non-JSON */ }
15
+ throw new Error(detail);
16
+ }
17
+ return resp.status === 204 ? null : resp.json();
18
+ }
19
+
20
+ const post = (path, body) =>
21
+ adminFetch(path, {
22
+ method: "POST",
23
+ headers: { "Content-Type": "application/json" },
24
+ body: JSON.stringify(body || {}),
25
+ });
26
+
27
+ export default function AdminPage({ auth }) {
28
+ const [overview, setOverview] = useState(null);
29
+ const [users, setUsers] = useState([]);
30
+ const [failed, setFailed] = useState([]);
31
+ const [constructs, setConstructs] = useState([]);
32
+ const [onlyUnverified, setOnlyUnverified] = useState(true);
33
+ const [error, setError] = useState("");
34
+ const [notice, setNotice] = useState("");
35
+
36
+ const reload = useCallback(() => {
37
+ setError("");
38
+ adminFetch("/api/admin/overview").then(setOverview).catch((e) => setError(e.message));
39
+ adminFetch("/api/admin/users").then(setUsers).catch(() => {});
40
+ adminFetch("/api/admin/jobs/failed").then(setFailed).catch(() => {});
41
+ adminFetch(
42
+ "/api/admin/constructs" + (onlyUnverified ? "?status=needs_verification" : "")
43
+ ).then(setConstructs).catch(() => {});
44
+ }, [onlyUnverified]);
45
+
46
+ useEffect(() => { reload(); }, [reload]);
47
+
48
+ if (!auth?.signed_in || !auth?.is_admin) {
49
+ return (
50
+ <div className="card">
51
+ <h3>Admin</h3>
52
+ <p className="hint">
53
+ This page requires an admin account.{" "}
54
+ <a href="/">Back to the platform</a>.
55
+ </p>
56
+ </div>
57
+ );
58
+ }
59
+
60
+ async function act(fn) {
61
+ setError("");
62
+ setNotice("");
63
+ try {
64
+ await fn();
65
+ reload();
66
+ } catch (e) {
67
+ setError(e.message);
68
+ }
69
+ }
70
+
71
+ return (
72
+ <>
73
+ {error && <div className="error-banner" onClick={() => setError("")}>{error}</div>}
74
+ {notice && (
75
+ <div className="card" style={{ borderColor: "var(--maroon)" }}>
76
+ <p><b>{notice}</b> (shown once - copy it now)</p>
77
+ </div>
78
+ )}
79
+
80
+ <div className="project-header">
81
+ <span className="project-title">Admin</span>
82
+ <a className="ghost header-btn" href="/">Back to platform</a>
83
+ </div>
84
+
85
+ {/* Overview */}
86
+ <div className="card">
87
+ <h3>Overview</h3>
88
+ {overview ? (
89
+ <p className="hint">
90
+ <b>{overview.users}</b> accounts ({overview.lab_users} lab tier,{" "}
91
+ {overview.signups_last_7_days} new this week) · <b>{overview.runs_total}</b>{" "}
92
+ runs total ({overview.runs_last_7_days} this week
93
+ {overview.runs_by_status?.failed ? `, ${overview.runs_by_status.failed} failed` : ""}) ·{" "}
94
+ <b>{overview.projects}</b> projects ({overview.anonymous_projects} anonymous) ·{" "}
95
+ <b>{overview.constructs_unverified}</b> library scales awaiting verification
96
+ </p>
97
+ ) : (
98
+ <p className="hint">Loading…</p>
99
+ )}
100
+ </div>
101
+
102
+ {/* Users */}
103
+ <div className="card">
104
+ <h3>Users</h3>
105
+ <div className="table-wrap">
106
+ <table className="docs">
107
+ <thead>
108
+ <tr><th>Email</th><th>Name</th><th>Role</th><th>Saved runs</th><th>Sign-in</th><th /></tr>
109
+ </thead>
110
+ <tbody>
111
+ {users.map((u) => (
112
+ <tr key={u.id}>
113
+ <td>{u.email}{u.is_admin ? " ★" : ""}</td>
114
+ <td>{u.name}</td>
115
+ <td><span className={`pill ${u.role === "lab" ? "completed" : "queued"}`}>{u.role}</span></td>
116
+ <td>{u.saved_runs}</td>
117
+ <td className="muted small">{u.google_only ? "Google" : "password"}</td>
118
+ <td>
119
+ <button
120
+ className="linkish"
121
+ onClick={() =>
122
+ act(() => post(`/api/admin/users/${u.id}/role`, {
123
+ role: u.role === "lab" ? "member" : "lab",
124
+ }))
125
+ }
126
+ >
127
+ {u.role === "lab" ? "Make member" : "Make lab (unlimited)"}
128
+ </button>{" "}
129
+ <button
130
+ className="linkish"
131
+ onClick={() =>
132
+ act(async () => {
133
+ const r = await post(`/api/admin/users/${u.id}/reset-password`);
134
+ setNotice(`Temporary password for ${r.email}: ${r.temporary_password}`);
135
+ })
136
+ }
137
+ >
138
+ Reset password
139
+ </button>{" "}
140
+ {!u.is_admin && (
141
+ <button
142
+ className="linkish danger"
143
+ onClick={() => {
144
+ if (window.confirm(`Delete ${u.email} and ALL their data?`)) {
145
+ act(() => adminFetch(`/api/admin/users/${u.id}`, { method: "DELETE" }));
146
+ }
147
+ }}
148
+ >
149
+ Delete
150
+ </button>
151
+ )}
152
+ </td>
153
+ </tr>
154
+ ))}
155
+ {users.length === 0 && (
156
+ <tr><td colSpan={6} className="muted">No accounts yet.</td></tr>
157
+ )}
158
+ </tbody>
159
+ </table>
160
+ </div>
161
+ </div>
162
+
163
+ {/* Failed runs */}
164
+ <div className="card">
165
+ <h3>Failed runs</h3>
166
+ {failed.length === 0 ? (
167
+ <p className="hint">None. 🎉</p>
168
+ ) : (
169
+ <div className="table-wrap">
170
+ <table className="docs">
171
+ <thead>
172
+ <tr><th>When</th><th>Corpus</th><th>Model</th><th>Error</th><th /></tr>
173
+ </thead>
174
+ <tbody>
175
+ {failed.map((j) => (
176
+ <tr key={j.id}>
177
+ <td className="muted">{j.created_at.replace("T", " ").slice(0, 16)}</td>
178
+ <td>{j.corpus_filename}</td>
179
+ <td className="muted small">{j.model_name}</td>
180
+ <td className="small" title={j.error_tail}>{j.error_tail.slice(0, 90)}</td>
181
+ <td>
182
+ {j.corpus_file_available ? (
183
+ <button
184
+ className="linkish"
185
+ onClick={() => act(() => post(`/api/admin/jobs/${j.id}/requeue`))}
186
+ >
187
+ Requeue
188
+ </button>
189
+ ) : (
190
+ <span className="muted small">file expired</span>
191
+ )}
192
+ </td>
193
+ </tr>
194
+ ))}
195
+ </tbody>
196
+ </table>
197
+ </div>
198
+ )}
199
+ </div>
200
+
201
+ {/* Verification queue */}
202
+ <div className="card">
203
+ <h3>Construct verification</h3>
204
+ <p className="hint">
205
+ For the RA workflow: mark a scale verified once its wording is checked
206
+ against the original publication (cross-reference the verification
207
+ checklist spreadsheet). Statuses set here are applied back to the
208
+ library files before production.
209
+ {" "}
210
+ <button className="linkish" onClick={() => setOnlyUnverified((v) => !v)}>
211
+ {onlyUnverified ? "Show all" : "Show unverified only"}
212
+ </button>
213
+ </p>
214
+ <div className="table-wrap">
215
+ <table className="docs">
216
+ <thead>
217
+ <tr><th>Scale</th><th>Category</th><th>Items</th><th>Status</th><th /></tr>
218
+ </thead>
219
+ <tbody>
220
+ {constructs.map((c) => (
221
+ <tr key={c.id}>
222
+ <td title={c.reference}>{c.name}</td>
223
+ <td className="muted small">{c.category}</td>
224
+ <td>{c.n_items}</td>
225
+ <td>
226
+ <span className={`pill ${c.verification_status === "verified" ? "completed" : "queued"}`}>
227
+ {c.verification_status.replace("_", " ")}
228
+ </span>
229
+ </td>
230
+ <td>
231
+ <button
232
+ className="linkish"
233
+ onClick={() =>
234
+ act(() => post(`/api/admin/constructs/${c.id}/verification`, {
235
+ status: c.verification_status === "verified"
236
+ ? "needs_verification"
237
+ : "verified",
238
+ }))
239
+ }
240
+ >
241
+ {c.verification_status === "verified" ? "Un-verify" : "Mark verified"}
242
+ </button>
243
+ </td>
244
+ </tr>
245
+ ))}
246
+ {constructs.length === 0 && (
247
+ <tr><td colSpan={5} className="muted">Nothing awaiting verification. 🎉</td></tr>
248
+ )}
249
+ </tbody>
250
+ </table>
251
+ </div>
252
+ </div>
253
+ </>
254
+ );
255
+ }
frontend/src/App.jsx CHANGED
@@ -1,7 +1,10 @@
1
  import { useEffect, useState } from "react";
2
  import { api } from "./api.js";
 
3
  import Workspace from "./Workspace.jsx";
4
 
 
 
5
  function relativeTime(iso) {
6
  if (!iso) return "";
7
  const then = new Date(iso.endsWith("Z") || iso.includes("+") ? iso : iso + "Z");
@@ -141,6 +144,9 @@ export default function App() {
141
  {auth?.signed_in ? (
142
  <>
143
  <span className="small">Hi, {auth.name}</span>
 
 
 
144
  <button className="header-btn" onClick={handleLogout}>
145
  Sign out
146
  </button>
@@ -251,6 +257,11 @@ export default function App() {
251
  </div>
252
  )}
253
 
 
 
 
 
 
254
  <div className="layout">
255
  <aside className="sidebar">
256
  <h2>
@@ -351,6 +362,7 @@ export default function App() {
351
  )}
352
  </main>
353
  </div>
 
354
  </div>
355
  );
356
  }
 
1
  import { useEffect, useState } from "react";
2
  import { api } from "./api.js";
3
+ import AdminPage from "./AdminPage.jsx";
4
  import Workspace from "./Workspace.jsx";
5
 
6
+ const IS_ADMIN_PATH = window.location.pathname === "/admin";
7
+
8
  function relativeTime(iso) {
9
  if (!iso) return "";
10
  const then = new Date(iso.endsWith("Z") || iso.includes("+") ? iso : iso + "Z");
 
144
  {auth?.signed_in ? (
145
  <>
146
  <span className="small">Hi, {auth.name}</span>
147
+ {auth.is_admin && !IS_ADMIN_PATH && (
148
+ <a className="header-btn" href="/admin">Admin</a>
149
+ )}
150
  <button className="header-btn" onClick={handleLogout}>
151
  Sign out
152
  </button>
 
257
  </div>
258
  )}
259
 
260
+ {IS_ADMIN_PATH ? (
261
+ <main className="main" style={{ maxWidth: 1100, margin: "0 auto", padding: "0 1rem" }}>
262
+ <AdminPage auth={auth} />
263
+ </main>
264
+ ) : (
265
  <div className="layout">
266
  <aside className="sidebar">
267
  <h2>
 
362
  )}
363
  </main>
364
  </div>
365
+ )}
366
  </div>
367
  );
368
  }