Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Admin page v1: user roles (lab tier), password resets, failed-run requeue, verification queue
Browse filesSynced 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 +5 -1
- MANUAL_TESTING.md +17 -0
- backend/app/admin.py +234 -0
- backend/app/auth.py +11 -0
- backend/app/main.py +24 -3
- backend/app/models.py +3 -0
- backend/static/assets/index-CeDfDyqW.js +0 -0
- backend/static/assets/index-DrFcy6bH.js +0 -0
- backend/static/index.html +1 -1
- backend/tests/test_admin.py +220 -0
- backend/tests/test_guide_and_samples.py +0 -38
- frontend/src/AdminPage.jsx +255 -0
- frontend/src/App.jsx +12 -0
.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 |
-
|
|
|
|
| 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
|
| 621 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 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 |
}
|