Spaces:
Running
Four-tier access model: self-governing admin, pre-login access, audit trail
Browse filesPI decisions 2026-07-22:
- Roles: pi | maintainer | lab | external (legacy "member" reads as
external). Lab members and above: unlimited saved runs.
- PI role holds escalation rights in-app (grant/revoke staff, act on
staff accounts, audit view); ADMIN_EMAILS is bootstrap + break-glass
only. Maintainers get the operational surface. Nobody can change
their own role.
- Construct verification is maintainer-only (the RA's job); PI/admin
keep a read-only queue.
- Access before sign-in: email-bound pre-assigned roles (staff-capable,
claimed on first password/Google sign-in) and signed 7-day invite
links (lab/external only, ?invite= signup flow).
- Append-only admin_audit table records every admin action; visible to
PI/env admins.
- Docs split: /guide = navigate-and-test; new /product page = access
model + capability matrix + architecture (moved from guide).
- MANUAL_TESTING.md +30 -7
- backend/app/admin.py +182 -17
- backend/app/auth.py +76 -3
- backend/app/guide.html +15 -126
- backend/app/main.py +58 -10
- backend/app/models.py +42 -3
- backend/app/product.html +355 -0
- backend/app/schemas.py +1 -0
- backend/static/assets/index-DRZ4Mumd.js +0 -0
- backend/static/assets/index-cmMEZ7Xf.js +0 -0
- backend/static/index.html +1 -1
- backend/tests/test_admin.py +203 -1
- backend/tests/test_api.py +7 -0
- frontend/src/AdminPage.jsx +182 -32
- frontend/src/App.jsx +36 -1
|
@@ -168,15 +168,38 @@ Checklist before giving the URL to real users:
|
|
| 168 |
|
| 169 |
## 12. Admin page (/admin)
|
| 170 |
|
| 171 |
-
Requires `ADMIN_EMAILS` to include your signed-in email (see .env.example)
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
-
1. Sign in with an allowlisted account: an "Admin" link appears in
|
| 174 |
-
non-admins (and signed-out visitors) see an access notice
|
| 175 |
2. Overview: account/run/project counters plus scales awaiting verification.
|
| 176 |
-
3. Users
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
4. Failed runs: a failed job lists with its error tail; Requeue re-runs it
|
| 181 |
(refused when the corpus file is already gone - anonymous retention).
|
| 182 |
5. Verification: mark a scale Verified; its "unverified" flag disappears
|
|
|
|
| 168 |
|
| 169 |
## 12. Admin page (/admin)
|
| 170 |
|
| 171 |
+
Requires `ADMIN_EMAILS` to include your signed-in email (see .env.example),
|
| 172 |
+
OR a pi/maintainer role. The env allowlist is bootstrap + break-glass; the
|
| 173 |
+
PI role carries the same escalation rights in-app. The full access model is
|
| 174 |
+
documented at /product.
|
| 175 |
|
| 176 |
+
1. Sign in with an allowlisted or staff account: an "Admin" link appears in
|
| 177 |
+
the header; non-admins (and signed-out visitors) see an access notice.
|
| 178 |
2. Overview: account/run/project counters plus scales awaiting verification.
|
| 179 |
+
3. Users - four tiers (external user, lab member, maintainer, PI):
|
| 180 |
+
- Set a user to "lab member" or above: their saved-run cap disappears
|
| 181 |
+
(check /api/auth/me shows max_saved_runs null).
|
| 182 |
+
- Set a user to "maintainer" or "PI": they also get this admin page.
|
| 183 |
+
- Escalation needs PI or env-admin rights: a maintainer gets 403 on
|
| 184 |
+
granting pi/maintainer, and on resetting the password of, changing the
|
| 185 |
+
role of, or deleting a pi/maintainer account. A PI-by-role CAN do all
|
| 186 |
+
of that (no env entry needed). Nobody can change their own role.
|
| 187 |
+
- Reset a password (temporary password shown once; old one stops working),
|
| 188 |
+
delete a user (removes all their data; self-deletion refused).
|
| 189 |
+
4. Access before sign-in:
|
| 190 |
+
- Pre-assign a role to an email (staff roles need PI/env rights): register
|
| 191 |
+
with that email afterwards - the account lands at that tier, the
|
| 192 |
+
assignment shows "claimed". Works for Google sign-ins too.
|
| 193 |
+
- Create an invite link (lab member or external only): open it in a
|
| 194 |
+
private window - the signup form announces the invite; registering
|
| 195 |
+
through it grants the role. Expired/garbage tokens refuse registration.
|
| 196 |
+
5. Construct verification is maintainer-only: PI/env admins see the queue
|
| 197 |
+
read-only (no action buttons; the API returns 403), a maintainer can mark
|
| 198 |
+
scales verified. Statuses are applied back to the library YAML before
|
| 199 |
+
production.
|
| 200 |
+
6. Audit trail (PI/env admins only; maintainers get 403 and don't see the
|
| 201 |
+
card): every role change, reset, deletion, invite, pre-assignment,
|
| 202 |
+
requeue, and verification appears with actor, target, and time.
|
| 203 |
4. Failed runs: a failed job lists with its error tail; Requeue re-runs it
|
| 204 |
(refused when the corpus file is already gone - anonymous retention).
|
| 205 |
5. Verification: mark a scale Verified; its "unverified" flag disappears
|
|
@@ -10,8 +10,12 @@ Four concrete pains drive this, nothing speculative:
|
|
| 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
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
"""
|
| 16 |
|
| 17 |
from __future__ import annotations
|
|
@@ -27,16 +31,47 @@ from sqlalchemy.orm import Session
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
raise HTTPException(403, "Admin access required.")
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
# ---------------------------------------------------------------- overview
|
|
@@ -46,9 +81,12 @@ def overview(db: Session = Depends(get_db), _admin: dict = Depends(require_admin
|
|
| 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 |
-
"
|
| 52 |
"projects": db.query(Project).count(),
|
| 53 |
"anonymous_projects": db.query(Project).filter_by(owner_user_id="").count(),
|
| 54 |
"corpora": db.query(Corpus).count(),
|
|
@@ -78,11 +116,12 @@ def list_users(db: Session = Depends(get_db), _admin: dict = Depends(require_adm
|
|
| 78 |
"id": u.id,
|
| 79 |
"email": u.email,
|
| 80 |
"name": u.name,
|
| 81 |
-
"role": u.role
|
| 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 |
]
|
|
@@ -93,34 +132,44 @@ def set_role(
|
|
| 93 |
user_id: str,
|
| 94 |
body: dict,
|
| 95 |
db: Session = Depends(get_db),
|
| 96 |
-
|
| 97 |
):
|
| 98 |
-
|
| 99 |
-
if
|
| 100 |
-
raise HTTPException(400, "Role must be
|
|
|
|
| 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),
|
| 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 |
if not user.password_hash:
|
| 119 |
raise HTTPException(
|
| 120 |
400, "This is a Google account - it has no password to reset (they sign in via Google)."
|
| 121 |
)
|
| 122 |
temp = secrets.token_urlsafe(9) # 12 chars, meets the minimum length
|
| 123 |
user.password_hash = auth.hash_password(temp)
|
|
|
|
| 124 |
db.commit()
|
| 125 |
return {"id": user.id, "email": user.email, "temporary_password": temp}
|
| 126 |
|
|
@@ -135,13 +184,117 @@ def delete_user(
|
|
| 135 |
user = db.get(User, user_id)
|
| 136 |
if user is None:
|
| 137 |
raise HTTPException(404, "User not found")
|
|
|
|
| 138 |
for project in db.query(Project).filter_by(owner_user_id=user_id).all():
|
| 139 |
retention.delete_project_cascade(db, project)
|
|
|
|
| 140 |
db.delete(user)
|
| 141 |
db.commit()
|
| 142 |
return None
|
| 143 |
|
| 144 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
# ----------------------------------------------------------- failed runs
|
| 146 |
@router.get("/jobs/failed")
|
| 147 |
def failed_jobs(db: Session = Depends(get_db), _admin: dict = Depends(require_admin)):
|
|
@@ -171,7 +324,7 @@ def failed_jobs(db: Session = Depends(get_db), _admin: dict = Depends(require_ad
|
|
| 171 |
|
| 172 |
@router.post("/jobs/{job_id}/requeue")
|
| 173 |
def requeue_job(
|
| 174 |
-
job_id: str, db: Session = Depends(get_db),
|
| 175 |
):
|
| 176 |
job = db.get(Job, job_id)
|
| 177 |
if job is None:
|
|
@@ -188,6 +341,7 @@ def requeue_job(
|
|
| 188 |
job.progress = 0.0
|
| 189 |
job.started_at = ""
|
| 190 |
job.finished_at = ""
|
|
|
|
| 191 |
db.commit()
|
| 192 |
jobs_module.submit_job(job.id)
|
| 193 |
return {"id": job.id, "status": "queued"}
|
|
@@ -222,11 +376,21 @@ def set_verification(
|
|
| 222 |
construct_id: str,
|
| 223 |
body: dict,
|
| 224 |
db: Session = Depends(get_db),
|
| 225 |
-
|
| 226 |
):
|
| 227 |
"""Operational overlay for the RA's workflow. The YAML library remains the
|
| 228 |
durable source of truth: statuses set here are exported and written back to
|
| 229 |
-
the library files by the developer before production (recorded decision).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
status = str(body.get("status", "")).strip()
|
| 231 |
if status not in ("verified", "needs_verification"):
|
| 232 |
raise HTTPException(400, "Status must be 'verified' or 'needs_verification'.")
|
|
@@ -234,5 +398,6 @@ def set_verification(
|
|
| 234 |
if construct is None:
|
| 235 |
raise HTTPException(404, "Construct not found")
|
| 236 |
construct.verification_status = status
|
|
|
|
| 237 |
db.commit()
|
| 238 |
return {"id": construct.id, "verification_status": construct.verification_status}
|
|
|
|
| 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, OR role
|
| 14 |
+
pi/maintainer - PI decision 2026-07-22). The app is self-governing: the
|
| 15 |
+
PI role carries escalation rights (grant/revoke staff, act on staff
|
| 16 |
+
accounts), maintainers get the operational surface only, and the env
|
| 17 |
+
allowlist is bootstrap + break-glass (seed the first PI; recover a
|
| 18 |
+
locked-out lab). Every mutating action lands in the admin_audit table.
|
| 19 |
"""
|
| 20 |
|
| 21 |
from __future__ import annotations
|
|
|
|
| 31 |
from . import auth, retention, storage
|
| 32 |
from . import jobs as jobs_module
|
| 33 |
from .db import get_db
|
| 34 |
+
from .models import AdminAudit, Construct, Corpus, Job, Project, RoleAssignment, User
|
| 35 |
|
| 36 |
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
| 37 |
|
| 38 |
|
| 39 |
+
def require_admin(request: Request, db: Session = Depends(get_db)) -> dict:
|
| 40 |
user = auth.get_current_user(request)
|
| 41 |
+
if user is None:
|
| 42 |
+
raise HTTPException(403, "Admin access required.")
|
| 43 |
+
env_admin = auth.is_admin(user.get("email"))
|
| 44 |
+
row = db.get(User, user["id"])
|
| 45 |
+
role = auth.normalize_role(row.role if row else None)
|
| 46 |
+
if not env_admin and not auth.role_is_staff(role):
|
| 47 |
raise HTTPException(403, "Admin access required.")
|
| 48 |
+
# can_escalate gates the power-expanding paths below (staff role grants,
|
| 49 |
+
# actions on staff accounts): PIs and env-allowlisted admins have it,
|
| 50 |
+
# maintainers get the operational surface only.
|
| 51 |
+
return {
|
| 52 |
+
**user,
|
| 53 |
+
"role": role,
|
| 54 |
+
"env_admin": env_admin,
|
| 55 |
+
"can_escalate": env_admin or role == "pi",
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _require_escalation_rights(admin: dict, target: User, action: str) -> None:
|
| 60 |
+
"""Actions on staff or env-allowlisted accounts require escalation rights
|
| 61 |
+
(PI role or env allowlist) - otherwise a maintainer could take over a PI
|
| 62 |
+
account (password reset), delete one, or mint more staff."""
|
| 63 |
+
if admin["can_escalate"]:
|
| 64 |
+
return
|
| 65 |
+
if auth.role_is_staff(target.role) or auth.is_admin(target.email):
|
| 66 |
+
raise HTTPException(
|
| 67 |
+
403, f"Only a PI (or allowlisted admin) can {action} a PI/maintainer account."
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _audit(db: Session, admin: dict, action: str, target: str, detail: str = "") -> None:
|
| 72 |
+
"""Append to the audit trail; committed together with the action itself."""
|
| 73 |
+
db.add(AdminAudit(actor_email=admin.get("email", ""), action=action,
|
| 74 |
+
target=target, detail=detail))
|
| 75 |
|
| 76 |
|
| 77 |
# ---------------------------------------------------------------- overview
|
|
|
|
| 81 |
runs_by_status = dict(
|
| 82 |
db.query(Job.status, func.count(Job.id)).group_by(Job.status).all()
|
| 83 |
)
|
| 84 |
+
users_by_role: dict[str, int] = {r: 0 for r in auth.ROLES}
|
| 85 |
+
for (role,) in db.query(User.role).all():
|
| 86 |
+
users_by_role[auth.normalize_role(role)] += 1
|
| 87 |
return {
|
| 88 |
"users": db.query(User).count(),
|
| 89 |
+
"users_by_role": users_by_role,
|
| 90 |
"projects": db.query(Project).count(),
|
| 91 |
"anonymous_projects": db.query(Project).filter_by(owner_user_id="").count(),
|
| 92 |
"corpora": db.query(Corpus).count(),
|
|
|
|
| 116 |
"id": u.id,
|
| 117 |
"email": u.email,
|
| 118 |
"name": u.name,
|
| 119 |
+
"role": auth.normalize_role(u.role),
|
| 120 |
"google_only": not u.password_hash,
|
| 121 |
"saved_runs": saved.get(u.id, 0),
|
| 122 |
"created_at": u.created_at,
|
| 123 |
+
"is_admin": auth.is_admin(u.email) or auth.role_is_staff(u.role),
|
| 124 |
+
"env_admin": auth.is_admin(u.email),
|
| 125 |
}
|
| 126 |
for u in db.query(User).order_by(User.created_at.desc()).all()
|
| 127 |
]
|
|
|
|
| 132 |
user_id: str,
|
| 133 |
body: dict,
|
| 134 |
db: Session = Depends(get_db),
|
| 135 |
+
admin: dict = Depends(require_admin),
|
| 136 |
):
|
| 137 |
+
raw = str(body.get("role", "")).strip().lower()
|
| 138 |
+
if raw not in auth.ROLES and raw != "member": # "member" = legacy external
|
| 139 |
+
raise HTTPException(400, f"Role must be one of: {', '.join(auth.ROLES)}.")
|
| 140 |
+
role = auth.normalize_role(raw)
|
| 141 |
user = db.get(User, user_id)
|
| 142 |
if user is None:
|
| 143 |
raise HTTPException(404, "User not found")
|
| 144 |
+
if user_id == admin["id"]:
|
| 145 |
+
raise HTTPException(400, "You cannot change your own role (ask another admin).")
|
| 146 |
+
_require_escalation_rights(admin, user, "change the role of")
|
| 147 |
+
if role in auth.STAFF_ROLES and not admin["can_escalate"]:
|
| 148 |
+
raise HTTPException(403, "Only a PI (or allowlisted admin) can grant PI/maintainer roles.")
|
| 149 |
+
old = auth.normalize_role(user.role)
|
| 150 |
user.role = role
|
| 151 |
+
_audit(db, admin, "set_role", user.email, f"{old} -> {role}")
|
| 152 |
db.commit()
|
| 153 |
return {"id": user.id, "role": user.role}
|
| 154 |
|
| 155 |
|
| 156 |
@router.post("/users/{user_id}/reset-password")
|
| 157 |
def reset_password(
|
| 158 |
+
user_id: str, db: Session = Depends(get_db), admin: dict = Depends(require_admin)
|
| 159 |
):
|
| 160 |
"""Generate a temporary password, shown ONCE in the response. The admin
|
| 161 |
passes it to the user, who should change it (or use Google sign-in)."""
|
| 162 |
user = db.get(User, user_id)
|
| 163 |
if user is None:
|
| 164 |
raise HTTPException(404, "User not found")
|
| 165 |
+
_require_escalation_rights(admin, user, "reset the password of")
|
| 166 |
if not user.password_hash:
|
| 167 |
raise HTTPException(
|
| 168 |
400, "This is a Google account - it has no password to reset (they sign in via Google)."
|
| 169 |
)
|
| 170 |
temp = secrets.token_urlsafe(9) # 12 chars, meets the minimum length
|
| 171 |
user.password_hash = auth.hash_password(temp)
|
| 172 |
+
_audit(db, admin, "reset_password", user.email)
|
| 173 |
db.commit()
|
| 174 |
return {"id": user.id, "email": user.email, "temporary_password": temp}
|
| 175 |
|
|
|
|
| 184 |
user = db.get(User, user_id)
|
| 185 |
if user is None:
|
| 186 |
raise HTTPException(404, "User not found")
|
| 187 |
+
_require_escalation_rights(admin, user, "delete")
|
| 188 |
for project in db.query(Project).filter_by(owner_user_id=user_id).all():
|
| 189 |
retention.delete_project_cascade(db, project)
|
| 190 |
+
_audit(db, admin, "delete_user", user.email, f"role was {auth.normalize_role(user.role)}")
|
| 191 |
db.delete(user)
|
| 192 |
db.commit()
|
| 193 |
return None
|
| 194 |
|
| 195 |
|
| 196 |
+
# ---------------------------------------------------------------- invites
|
| 197 |
+
@router.post("/invites", status_code=201)
|
| 198 |
+
def create_invite(
|
| 199 |
+
body: dict,
|
| 200 |
+
db: Session = Depends(get_db),
|
| 201 |
+
admin: dict = Depends(require_admin),
|
| 202 |
+
):
|
| 203 |
+
"""Signed, expiring invite link: whoever registers through it lands at
|
| 204 |
+
the invited tier (external/lab only - staff is granted, never invited).
|
| 205 |
+
Stateless, so it cannot be revoked early; creation is audited."""
|
| 206 |
+
role = str(body.get("role", "")).strip().lower()
|
| 207 |
+
try:
|
| 208 |
+
token, expires = auth.create_invite_token(role, admin.get("email", ""))
|
| 209 |
+
except ValueError as exc:
|
| 210 |
+
raise HTTPException(400, str(exc)) from exc
|
| 211 |
+
_audit(db, admin, "invite_created", auth.normalize_role(role), f"expires {expires}")
|
| 212 |
+
db.commit()
|
| 213 |
+
return {"token": token, "role": auth.normalize_role(role), "expires_at": expires,
|
| 214 |
+
"ttl_days": auth.invite_ttl_days()}
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# ------------------------------------------------------ pre-assigned roles
|
| 218 |
+
@router.get("/role-assignments")
|
| 219 |
+
def list_role_assignments(db: Session = Depends(get_db), _admin: dict = Depends(require_admin)):
|
| 220 |
+
return [
|
| 221 |
+
{"id": a.id, "email": a.email, "role": auth.normalize_role(a.role),
|
| 222 |
+
"assigned_by": a.assigned_by, "created_at": a.created_at,
|
| 223 |
+
"claimed_at": a.claimed_at or None}
|
| 224 |
+
for a in db.query(RoleAssignment)
|
| 225 |
+
.order_by(RoleAssignment.created_at.desc()).limit(100).all()
|
| 226 |
+
]
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
@router.post("/role-assignments", status_code=201)
|
| 230 |
+
def create_role_assignment(
|
| 231 |
+
body: dict,
|
| 232 |
+
db: Session = Depends(get_db),
|
| 233 |
+
admin: dict = Depends(require_admin),
|
| 234 |
+
):
|
| 235 |
+
"""Bind a role to an email BEFORE the account exists: whoever first signs
|
| 236 |
+
in with this email (password or Google) lands at this tier. This is how
|
| 237 |
+
an external collaborator gets full credentials without touching env vars.
|
| 238 |
+
Staff assignments require escalation rights, same as the Users table."""
|
| 239 |
+
email = str(body.get("email", "")).strip().lower()
|
| 240 |
+
role = auth.normalize_role(str(body.get("role", "")))
|
| 241 |
+
if not auth.valid_email(email):
|
| 242 |
+
raise HTTPException(400, "Please enter a valid email address.")
|
| 243 |
+
if str(body.get("role", "")).strip().lower() not in auth.ROLES:
|
| 244 |
+
raise HTTPException(400, f"Role must be one of: {', '.join(auth.ROLES)}.")
|
| 245 |
+
if role in auth.STAFF_ROLES and not admin["can_escalate"]:
|
| 246 |
+
raise HTTPException(403, "Only a PI (or allowlisted admin) can pre-assign PI/maintainer roles.")
|
| 247 |
+
if db.query(User).filter_by(email=email).first():
|
| 248 |
+
raise HTTPException(409, "This email already has an account - change their role in the Users table.")
|
| 249 |
+
existing = db.query(RoleAssignment).filter_by(email=email).first()
|
| 250 |
+
if existing is not None:
|
| 251 |
+
if auth.role_is_staff(existing.role) and not admin["can_escalate"]:
|
| 252 |
+
raise HTTPException(403, "Only a PI (or allowlisted admin) can change a staff pre-assignment.")
|
| 253 |
+
existing.role = role
|
| 254 |
+
existing.assigned_by = admin.get("email", "")
|
| 255 |
+
existing.claimed_at = ""
|
| 256 |
+
assignment = existing
|
| 257 |
+
else:
|
| 258 |
+
assignment = RoleAssignment(email=email, role=role, assigned_by=admin.get("email", ""))
|
| 259 |
+
db.add(assignment)
|
| 260 |
+
_audit(db, admin, "role_preassigned", email, role)
|
| 261 |
+
db.commit()
|
| 262 |
+
return {"id": assignment.id, "email": email, "role": role}
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
@router.delete("/role-assignments/{assignment_id}", status_code=204)
|
| 266 |
+
def delete_role_assignment(
|
| 267 |
+
assignment_id: str,
|
| 268 |
+
db: Session = Depends(get_db),
|
| 269 |
+
admin: dict = Depends(require_admin),
|
| 270 |
+
):
|
| 271 |
+
assignment = db.get(RoleAssignment, assignment_id)
|
| 272 |
+
if assignment is None:
|
| 273 |
+
raise HTTPException(404, "Assignment not found")
|
| 274 |
+
if auth.role_is_staff(assignment.role) and not admin["can_escalate"]:
|
| 275 |
+
raise HTTPException(403, "Only a PI (or allowlisted admin) can remove a staff pre-assignment.")
|
| 276 |
+
_audit(db, admin, "preassignment_removed", assignment.email,
|
| 277 |
+
auth.normalize_role(assignment.role))
|
| 278 |
+
db.delete(assignment)
|
| 279 |
+
db.commit()
|
| 280 |
+
return None
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
# -------------------------------------------------------------- audit log
|
| 284 |
+
@router.get("/audit")
|
| 285 |
+
def audit_log(db: Session = Depends(get_db), admin: dict = Depends(require_admin)):
|
| 286 |
+
"""Top-down oversight is the PI's (and env admin's) view - maintainers
|
| 287 |
+
work the operational cards but don't review each other's actions."""
|
| 288 |
+
if not admin["can_escalate"]:
|
| 289 |
+
raise HTTPException(403, "The audit trail is visible to PIs and allowlisted admins.")
|
| 290 |
+
rows = db.query(AdminAudit).order_by(AdminAudit.at.desc(), AdminAudit.id.desc()).limit(100).all()
|
| 291 |
+
return [
|
| 292 |
+
{"at": r.at, "actor": r.actor_email, "action": r.action,
|
| 293 |
+
"target": r.target, "detail": r.detail}
|
| 294 |
+
for r in rows
|
| 295 |
+
]
|
| 296 |
+
|
| 297 |
+
|
| 298 |
# ----------------------------------------------------------- failed runs
|
| 299 |
@router.get("/jobs/failed")
|
| 300 |
def failed_jobs(db: Session = Depends(get_db), _admin: dict = Depends(require_admin)):
|
|
|
|
| 324 |
|
| 325 |
@router.post("/jobs/{job_id}/requeue")
|
| 326 |
def requeue_job(
|
| 327 |
+
job_id: str, db: Session = Depends(get_db), admin: dict = Depends(require_admin)
|
| 328 |
):
|
| 329 |
job = db.get(Job, job_id)
|
| 330 |
if job is None:
|
|
|
|
| 341 |
job.progress = 0.0
|
| 342 |
job.started_at = ""
|
| 343 |
job.finished_at = ""
|
| 344 |
+
_audit(db, admin, "requeue_job", job.id[:8])
|
| 345 |
db.commit()
|
| 346 |
jobs_module.submit_job(job.id)
|
| 347 |
return {"id": job.id, "status": "queued"}
|
|
|
|
| 376 |
construct_id: str,
|
| 377 |
body: dict,
|
| 378 |
db: Session = Depends(get_db),
|
| 379 |
+
admin: dict = Depends(require_admin),
|
| 380 |
):
|
| 381 |
"""Operational overlay for the RA's workflow. The YAML library remains the
|
| 382 |
durable source of truth: statuses set here are exported and written back to
|
| 383 |
+
the library files by the developer before production (recorded decision).
|
| 384 |
+
|
| 385 |
+
Verification is the MAINTAINER's job (PI decision 2026-07-22): the queue is
|
| 386 |
+
visible to all staff, but only maintainers mark scales - the trail then
|
| 387 |
+
shows the responsible RA, not whichever admin clicked. PI/env admins keep
|
| 388 |
+
read access; to verify, hold the maintainer role."""
|
| 389 |
+
if admin["role"] != "maintainer":
|
| 390 |
+
raise HTTPException(
|
| 391 |
+
403, "Construct verification is done by maintainers. "
|
| 392 |
+
"PI/admin accounts have read access to the queue."
|
| 393 |
+
)
|
| 394 |
status = str(body.get("status", "")).strip()
|
| 395 |
if status not in ("verified", "needs_verification"):
|
| 396 |
raise HTTPException(400, "Status must be 'verified' or 'needs_verification'.")
|
|
|
|
| 398 |
if construct is None:
|
| 399 |
raise HTTPException(404, "Construct not found")
|
| 400 |
construct.verification_status = status
|
| 401 |
+
_audit(db, admin, "set_verification", construct.name, status)
|
| 402 |
db.commit()
|
| 403 |
return {"id": construct.id, "verification_status": construct.verification_status}
|
|
@@ -77,8 +77,9 @@ def cookies_secure() -> bool:
|
|
| 77 |
|
| 78 |
|
| 79 |
def admin_emails() -> set[str]:
|
| 80 |
-
"""Comma-separated allowlist;
|
| 81 |
-
|
|
|
|
| 82 |
raw = os.environ.get("ADMIN_EMAILS", "")
|
| 83 |
return {e.strip().lower() for e in raw.split(",") if e.strip()}
|
| 84 |
|
|
@@ -87,6 +88,42 @@ def is_admin(email: str | None) -> bool:
|
|
| 87 |
return bool(email) and email.strip().lower() in admin_emails()
|
| 88 |
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
# ---------------------------------------------------------- passwords
|
| 91 |
def hash_password(password: str) -> str:
|
| 92 |
salt = secrets.token_bytes(16)
|
|
@@ -150,10 +187,46 @@ def get_current_user(request: Request) -> dict | None:
|
|
| 150 |
"id": data["uid"],
|
| 151 |
"email": data.get("email", ""),
|
| 152 |
"name": data.get("name", ""),
|
| 153 |
-
|
|
|
|
|
|
|
| 154 |
}
|
| 155 |
|
| 156 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
# ------------------------------------------- anonymous daily run counter
|
| 158 |
def _today() -> str:
|
| 159 |
return datetime.now(timezone.utc).date().isoformat()
|
|
|
|
| 77 |
|
| 78 |
|
| 79 |
def admin_emails() -> set[str]:
|
| 80 |
+
"""Comma-separated allowlist; env-granted, so it bootstraps the first
|
| 81 |
+
admin and can never be locked out by DB state. See roles below for the
|
| 82 |
+
DB-granted staff tiers (PI decision 2026-07-22)."""
|
| 83 |
raw = os.environ.get("ADMIN_EMAILS", "")
|
| 84 |
return {e.strip().lower() for e in raw.split(",") if e.strip()}
|
| 85 |
|
|
|
|
| 88 |
return bool(email) and email.strip().lower() in admin_emails()
|
| 89 |
|
| 90 |
|
| 91 |
+
# ------------------------------------------------------------- user roles
|
| 92 |
+
# Four tiers (PI decision 2026-07-22): pi | maintainer | lab | external.
|
| 93 |
+
# * external - default on signup; saved-run cap applies.
|
| 94 |
+
# * lab - lab members: unlimited saved runs.
|
| 95 |
+
# * maintainer - lab privileges + the /admin operational surface
|
| 96 |
+
# (user management for lab/external, resets, requeue,
|
| 97 |
+
# verification queue, invites).
|
| 98 |
+
# * pi - maintainer surface + escalation rights: grant/revoke
|
| 99 |
+
# staff roles and act on staff accounts. The app is
|
| 100 |
+
# self-governing; ADMIN_EMAILS is bootstrap + break-glass
|
| 101 |
+
# only (seed the first PI, recover a locked-out lab).
|
| 102 |
+
# Escalation therefore requires pi-or-env-admin (admin.py guards), so a
|
| 103 |
+
# maintainer - or a compromised maintainer session - cannot mint staff.
|
| 104 |
+
ROLES = ("external", "lab", "maintainer", "pi")
|
| 105 |
+
UNLIMITED_ROLES = frozenset({"lab", "maintainer", "pi"})
|
| 106 |
+
STAFF_ROLES = frozenset({"maintainer", "pi"})
|
| 107 |
+
INVITABLE_ROLES = frozenset({"external", "lab"}) # staff is granted, never invited
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def normalize_role(role: str | None) -> str:
|
| 111 |
+
"""Map stored roles to the current scheme ('member' predates 'external')."""
|
| 112 |
+
role = (role or "").strip().lower()
|
| 113 |
+
if role == "member":
|
| 114 |
+
return "external"
|
| 115 |
+
return role if role in ROLES else "external"
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def role_unlimited(role: str | None) -> bool:
|
| 119 |
+
"""Lab members and above: no saved-run cap."""
|
| 120 |
+
return normalize_role(role) in UNLIMITED_ROLES
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def role_is_staff(role: str | None) -> bool:
|
| 124 |
+
return normalize_role(role) in STAFF_ROLES
|
| 125 |
+
|
| 126 |
+
|
| 127 |
# ---------------------------------------------------------- passwords
|
| 128 |
def hash_password(password: str) -> str:
|
| 129 |
salt = secrets.token_bytes(16)
|
|
|
|
| 187 |
"id": data["uid"],
|
| 188 |
"email": data.get("email", ""),
|
| 189 |
"name": data.get("name", ""),
|
| 190 |
+
# placeholder only - real role lives in the users table (queried per
|
| 191 |
+
# request in main.py/admin.py so role changes apply without re-login)
|
| 192 |
+
"tier": "external",
|
| 193 |
}
|
| 194 |
|
| 195 |
|
| 196 |
+
# ------------------------------------------------------------ invite links
|
| 197 |
+
# Signed, expiring tokens the PI copies into Slack; whoever registers through
|
| 198 |
+
# one lands at the invited tier instead of external. Stateless (no DB row):
|
| 199 |
+
# the cost is that an invite cannot be revoked before it expires - acceptable
|
| 200 |
+
# for a 7-day lab onboarding link, and every creation/redemption is audited.
|
| 201 |
+
INVITE_TTL_DAYS_DEFAULT = 7
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def invite_ttl_days() -> int:
|
| 205 |
+
return int(os.environ.get("CCR_INVITE_TTL_DAYS", INVITE_TTL_DAYS_DEFAULT))
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def create_invite_token(role: str, invited_by: str) -> tuple[str, str]:
|
| 209 |
+
"""Returns (token, expires_at ISO date). Role must be invitable."""
|
| 210 |
+
from datetime import timedelta
|
| 211 |
+
|
| 212 |
+
role = normalize_role(role)
|
| 213 |
+
if role not in INVITABLE_ROLES:
|
| 214 |
+
raise ValueError(f"Only these roles can be invited: {', '.join(sorted(INVITABLE_ROLES))}.")
|
| 215 |
+
expires = (datetime.now(timezone.utc) + timedelta(days=invite_ttl_days())).date().isoformat()
|
| 216 |
+
return sign_payload({"invite": role, "by": invited_by, "exp": expires}), expires
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def verify_invite_token(token: str | None) -> str | None:
|
| 220 |
+
"""Role granted by a valid, unexpired invite token; None otherwise."""
|
| 221 |
+
data = verify_payload(token)
|
| 222 |
+
if not data or "invite" not in data:
|
| 223 |
+
return None
|
| 224 |
+
if str(data.get("exp", "")) < datetime.now(timezone.utc).date().isoformat():
|
| 225 |
+
return None # expired (dates are ISO, so string compare is correct)
|
| 226 |
+
role = normalize_role(str(data["invite"]))
|
| 227 |
+
return role if role in INVITABLE_ROLES else None
|
| 228 |
+
|
| 229 |
+
|
| 230 |
# ------------------------------------------- anonymous daily run counter
|
| 231 |
def _today() -> str:
|
| 232 |
return datetime.now(timezone.utc).date().isoformat()
|
|
@@ -120,13 +120,15 @@ systematically stress-testing the platform, but <b>your real data is the best te
|
|
| 120 |
have</b>.</p>
|
| 121 |
|
| 122 |
<h2 id="limits">Limits at a glance</h2>
|
| 123 |
-
<p>
|
| 124 |
-
|
|
|
|
|
|
|
| 125 |
<div class="tablewrap">
|
| 126 |
<table>
|
| 127 |
-
<tr><th>Limit</th><th>Signed out</th><th>
|
| 128 |
<tr><td>Upload size</td><td>5 MB</td><td>50 MB</td><td>50 MB</td></tr>
|
| 129 |
-
<tr><td>Rows per file</td><td>200</td><td>
|
| 130 |
<tr><td>Runs per day</td><td>3, then sign in</td><td>unlimited</td><td>unlimited</td></tr>
|
| 131 |
<tr><td>Saved runs kept</td><td>none (file deleted after each run)</td><td>15</td><td>unlimited</td></tr>
|
| 132 |
</table>
|
|
@@ -291,9 +293,9 @@ pages don't update.</p>
|
|
| 291 |
<ol>
|
| 292 |
<li>Sign in, upload, run: no ANONYMOUS_DATA_REMOVED warning; re-running the same corpus
|
| 293 |
works (file kept).</li>
|
| 294 |
-
<li>Saved-run budget: the Step 3 card shows "N of M saved runs used". Lab
|
| 295 |
-
|
| 296 |
-
|
| 297 |
<li>Ownership: your projects are invisible to signed-out visitors and other accounts.
|
| 298 |
Anonymous projects stay shared.</li>
|
| 299 |
</ol>
|
|
@@ -308,126 +310,13 @@ pages don't update.</p>
|
|
| 308 |
dupes): identical scores for identical texts, less compute.</li>
|
| 309 |
</ol>
|
| 310 |
|
| 311 |
-
<h2 id="architecture">
|
| 312 |
-
<p>
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
the platform.</p>
|
| 316 |
|
| 317 |
-
<
|
| 318 |
-
<figure>
|
| 319 |
-
<svg viewBox="0 0 760 300" xmlns="http://www.w3.org/2000/svg" role="img"
|
| 320 |
-
aria-label="Architecture: browser talks to one app container, which uses Postgres, R2, and Supabase/Google auth.">
|
| 321 |
-
<defs>
|
| 322 |
-
<marker id="arw" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
|
| 323 |
-
<path d="M0,0 L7,3 L0,6 Z" fill="var(--muted)"/>
|
| 324 |
-
</marker>
|
| 325 |
-
</defs>
|
| 326 |
-
<!-- browser -->
|
| 327 |
-
<rect class="box" x="20" y="120" width="150" height="60" rx="8"/>
|
| 328 |
-
<text class="lbl" x="95" y="147" text-anchor="middle">Your browser</text>
|
| 329 |
-
<text class="lbl-sm" x="95" y="165" text-anchor="middle">the web interface</text>
|
| 330 |
-
<!-- app container -->
|
| 331 |
-
<rect class="box-accent" x="270" y="90" width="220" height="120" rx="10"/>
|
| 332 |
-
<text class="lbl" x="380" y="115" text-anchor="middle">One app container</text>
|
| 333 |
-
<text class="lbl-sm" x="380" y="135" text-anchor="middle">API + interface (FastAPI/React)</text>
|
| 334 |
-
<text class="lbl-sm" x="380" y="152" text-anchor="middle">embedding models run here</text>
|
| 335 |
-
<text class="lbl-sm" x="380" y="169" text-anchor="middle">(MiniLM, E5) — no external AI</text>
|
| 336 |
-
<text class="lbl-sm" x="380" y="190" text-anchor="middle">hosted on Hugging Face</text>
|
| 337 |
-
<!-- stores -->
|
| 338 |
-
<rect class="box" x="590" y="30" width="150" height="52" rx="8"/>
|
| 339 |
-
<text class="lbl" x="665" y="52" text-anchor="middle">Postgres</text>
|
| 340 |
-
<text class="lbl-sm" x="665" y="69" text-anchor="middle">records (Supabase)</text>
|
| 341 |
-
<rect class="box" x="590" y="124" width="150" height="52" rx="8"/>
|
| 342 |
-
<text class="lbl" x="665" y="146" text-anchor="middle">Object storage</text>
|
| 343 |
-
<text class="lbl-sm" x="665" y="163" text-anchor="middle">files (Cloudflare R2)</text>
|
| 344 |
-
<rect class="box" x="590" y="218" width="150" height="52" rx="8"/>
|
| 345 |
-
<text class="lbl" x="665" y="240" text-anchor="middle">Sign-in</text>
|
| 346 |
-
<text class="lbl-sm" x="665" y="257" text-anchor="middle">Google / Supabase</text>
|
| 347 |
-
<!-- arrows -->
|
| 348 |
-
<path class="flow" d="M170,150 L268,150"/>
|
| 349 |
-
<path class="flow" d="M492,140 L588,58"/>
|
| 350 |
-
<path class="flow" d="M492,150 L588,150"/>
|
| 351 |
-
<path class="flow" d="M492,162 L588,244"/>
|
| 352 |
-
</svg>
|
| 353 |
-
<figcaption>The interface, the API, and the embedding models all live in one
|
| 354 |
-
container. Around it: a persistent database for records, object storage for
|
| 355 |
-
uploaded files, and Google/Supabase for sign-in. Every piece is on a free tier.</figcaption>
|
| 356 |
-
</figure>
|
| 357 |
-
|
| 358 |
-
<h3>What happens during a run</h3>
|
| 359 |
-
<figure>
|
| 360 |
-
<svg viewBox="0 0 760 190" xmlns="http://www.w3.org/2000/svg" role="img"
|
| 361 |
-
aria-label="Data flow: upload, quality checks, embed items and texts, cosine similarity, scores, results and exports.">
|
| 362 |
-
<defs>
|
| 363 |
-
<marker id="arw2" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
|
| 364 |
-
<path d="M0,0 L7,3 L0,6 Z" fill="var(--muted)"/>
|
| 365 |
-
</marker>
|
| 366 |
-
</defs>
|
| 367 |
-
<g>
|
| 368 |
-
<rect class="box" x="15" y="30" width="120" height="54" rx="8"/>
|
| 369 |
-
<text class="lbl-sm" x="75" y="52" text-anchor="middle">1. Upload</text>
|
| 370 |
-
<text class="lbl-sm" x="75" y="68" text-anchor="middle">corpus + construct</text>
|
| 371 |
-
|
| 372 |
-
<rect class="box" x="165" y="30" width="120" height="54" rx="8"/>
|
| 373 |
-
<text class="lbl-sm" x="225" y="52" text-anchor="middle">2. Quality checks</text>
|
| 374 |
-
<text class="lbl-sm" x="225" y="68" text-anchor="middle">language, warnings</text>
|
| 375 |
-
|
| 376 |
-
<rect class="box-accent" x="315" y="30" width="130" height="54" rx="8"/>
|
| 377 |
-
<text class="lbl-sm" x="380" y="52" text-anchor="middle">3. Embed both</text>
|
| 378 |
-
<text class="lbl-sm" x="380" y="68" text-anchor="middle">items & texts, one model</text>
|
| 379 |
-
|
| 380 |
-
<rect class="box-accent" x="475" y="30" width="130" height="54" rx="8"/>
|
| 381 |
-
<text class="lbl-sm" x="540" y="52" text-anchor="middle">4. Cosine similarity</text>
|
| 382 |
-
<text class="lbl-sm" x="540" y="68" text-anchor="middle">text × each item</text>
|
| 383 |
-
|
| 384 |
-
<rect class="box" x="635" y="30" width="110" height="54" rx="8"/>
|
| 385 |
-
<text class="lbl-sm" x="690" y="52" text-anchor="middle">5. CCR score</text>
|
| 386 |
-
<text class="lbl-sm" x="690" y="68" text-anchor="middle">mean of items</text>
|
| 387 |
-
|
| 388 |
-
<rect class="box" x="240" y="120" width="280" height="50" rx="8"/>
|
| 389 |
-
<text class="lbl-sm" x="380" y="141" text-anchor="middle">6. Results: distributions, per-item loadings,</text>
|
| 390 |
-
<text class="lbl-sm" x="380" y="157" text-anchor="middle">top/bottom texts + CSV, metadata, repro script</text>
|
| 391 |
-
</g>
|
| 392 |
-
<path class="flow" style="marker-end:url(#arw2)" d="M135,57 L163,57"/>
|
| 393 |
-
<path class="flow" style="marker-end:url(#arw2)" d="M285,57 L313,57"/>
|
| 394 |
-
<path class="flow" style="marker-end:url(#arw2)" d="M445,57 L473,57"/>
|
| 395 |
-
<path class="flow" style="marker-end:url(#arw2)" d="M605,57 L633,57"/>
|
| 396 |
-
<path class="flow" style="marker-end:url(#arw2)" d="M690,84 L690,120 L522,145"/>
|
| 397 |
-
</svg>
|
| 398 |
-
<figcaption>CCR in one line: embed the validated scale items and your texts with
|
| 399 |
-
the <em>same</em> model, then each text's cosine similarity to the items is its
|
| 400 |
-
loading; the mean across items is the CCR score. It is deterministic embeddings
|
| 401 |
-
plus arithmetic — no LLM, no prompting, no randomness.</figcaption>
|
| 402 |
-
</figure>
|
| 403 |
-
|
| 404 |
-
<h3>Where your data lives (and for how long)</h3>
|
| 405 |
-
<ul>
|
| 406 |
-
<li><b>Records</b> (account, projects, run results and summaries) → the Postgres
|
| 407 |
-
database. Persistent, backed up.</li>
|
| 408 |
-
<li><b>Uploaded files</b> → object storage. Signed-in users' files persist;
|
| 409 |
-
anonymous uploads are deleted the moment their analysis finishes.</li>
|
| 410 |
-
<li><b>Anonymous sessions</b> are wiped entirely after 24 hours; nothing you
|
| 411 |
-
upload anonymously is kept.</li>
|
| 412 |
-
<li><b>Reproducibility</b>: every run also records the exact model version, an
|
| 413 |
-
item-wording hash, and package versions, so the downloadable script
|
| 414 |
-
reproduces the numbers on any machine.</li>
|
| 415 |
-
</ul>
|
| 416 |
-
|
| 417 |
-
<h3>A few engineering choices worth knowing</h3>
|
| 418 |
-
<ul>
|
| 419 |
-
<li><b>Same model for items and texts</b>, always — mixing models makes scores
|
| 420 |
-
non-comparable, so the platform records the model on every run and warns you.</li>
|
| 421 |
-
<li><b>MiniLM is the default</b> because it is the reference model in the published
|
| 422 |
-
CCR work; the stronger E5 models are available and clearly labelled.</li>
|
| 423 |
-
<li><b>Re-running a new construct on the same corpus is near-instant</b> — the
|
| 424 |
-
text embeddings are cached and reused (identical every time), so only the
|
| 425 |
-
cheap similarity step repeats.</li>
|
| 426 |
-
<li><b>Warnings never silently "fix" your data</b> — the platform surfaces issues
|
| 427 |
-
(wrong language, truncation, duplicates) and lets you decide.</li>
|
| 428 |
-
</ul>
|
| 429 |
-
|
| 430 |
-
<h2 id="feedback">12. Found something off?</h2>
|
| 431 |
<p>Anything that doesn't match what this guide says it should do - or anything confusing,
|
| 432 |
slow, or missing - post it in the lab's <b>#ccr Slack channel</b>: the 🐞 thread for bugs,
|
| 433 |
the 💡 thread for ideas and feature requests. One line is enough; note the section number
|
|
|
|
| 120 |
have</b>.</p>
|
| 121 |
|
| 122 |
<h2 id="limits">Limits at a glance</h2>
|
| 123 |
+
<p>Accounts come in four tiers, set by the admins: <b>external user</b> (the default on
|
| 124 |
+
sign-up), <b>lab member</b>, <b>maintainer</b>, and <b>PI</b>. Signing in lifts the
|
| 125 |
+
anonymous caps; lab members and above have no saved-run cap. The row limit is usually
|
| 126 |
+
what you hit first, not the file size.</p>
|
| 127 |
<div class="tablewrap">
|
| 128 |
<table>
|
| 129 |
+
<tr><th>Limit</th><th>Signed out</th><th>External user</th><th>Lab member +</th></tr>
|
| 130 |
<tr><td>Upload size</td><td>5 MB</td><td>50 MB</td><td>50 MB</td></tr>
|
| 131 |
+
<tr><td>Rows per file</td><td>200</td><td>50,000</td><td>50,000</td></tr>
|
| 132 |
<tr><td>Runs per day</td><td>3, then sign in</td><td>unlimited</td><td>unlimited</td></tr>
|
| 133 |
<tr><td>Saved runs kept</td><td>none (file deleted after each run)</td><td>15</td><td>unlimited</td></tr>
|
| 134 |
</table>
|
|
|
|
| 293 |
<ol>
|
| 294 |
<li>Sign in, upload, run: no ANONYMOUS_DATA_REMOVED warning; re-running the same corpus
|
| 295 |
works (file kept).</li>
|
| 296 |
+
<li>Saved-run budget: the Step 3 card shows "N of M saved runs used". Lab members and
|
| 297 |
+
above have no cap; external accounts get 15. At the cap, new runs are refused until
|
| 298 |
+
you delete old runs/projects (nothing is auto-deleted).</li>
|
| 299 |
<li>Ownership: your projects are invisible to signed-out visitors and other accounts.
|
| 300 |
Anonymous projects stay shared.</li>
|
| 301 |
</ol>
|
|
|
|
| 310 |
dupes): identical scores for identical texts, less compute.</li>
|
| 311 |
</ol>
|
| 312 |
|
| 313 |
+
<h2 id="architecture">10. Under the hood</h2>
|
| 314 |
+
<p>Architecture, data flow, data retention, and the full access/roles model
|
| 315 |
+
(tiers, invite links, pre-assigned roles, audit trail) live on their own page:
|
| 316 |
+
<a href="/product"><b>Product & Architecture →</b></a>. This guide stays
|
| 317 |
+
focused on using and testing the platform.</p>
|
| 318 |
|
| 319 |
+
<h2 id="feedback">11. Found something off?</h2>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
<p>Anything that doesn't match what this guide says it should do - or anything confusing,
|
| 321 |
slow, or missing - post it in the lab's <b>#ccr Slack channel</b>: the 🐞 thread for bugs,
|
| 322 |
the 💡 thread for ideas and feature requests. One line is enough; note the section number
|
|
@@ -13,6 +13,7 @@ import json
|
|
| 13 |
import os
|
| 14 |
import uuid
|
| 15 |
from contextlib import asynccontextmanager
|
|
|
|
| 16 |
from pathlib import Path
|
| 17 |
|
| 18 |
from fastapi import Depends, FastAPI, HTTPException, Request, Response, UploadFile
|
|
@@ -31,7 +32,7 @@ from .construct_files import parse_construct_file
|
|
| 31 |
from .construct_lib import sync_library
|
| 32 |
from .db import DATA_DIR, Base, SessionLocal, auto_migrate_sqlite, engine, get_db
|
| 33 |
from .ingest import IngestError, load_corpus, max_rows as corpus_max_rows, suggest_text_column
|
| 34 |
-
from .models import Construct, Corpus, Job, Project, User
|
| 35 |
from .reproducibility import (
|
| 36 |
requirements_filename,
|
| 37 |
requirements_text,
|
|
@@ -255,13 +256,15 @@ def auth_me(
|
|
| 255 |
):
|
| 256 |
if user:
|
| 257 |
row = db.get(User, user["id"])
|
| 258 |
-
role = (row.role if row else None)
|
| 259 |
return {
|
| 260 |
"signed_in": True,
|
| 261 |
"name": user["name"],
|
| 262 |
"email": user["email"],
|
| 263 |
"role": role,
|
| 264 |
-
|
|
|
|
|
|
|
| 265 |
# max_rows is NOT unlimited for signed-in users: CCR_MAX_ROWS is a
|
| 266 |
# global ingest ceiling and is usually the limit that actually
|
| 267 |
# binds (deployed instances set it well below the byte ceiling).
|
|
@@ -270,8 +273,8 @@ def auth_me(
|
|
| 270 |
"limits": {"max_bytes": max_upload_bytes(), "max_rows": corpus_max_rows()},
|
| 271 |
"usage": {
|
| 272 |
"saved_runs": _saved_runs_used(db, user["id"]),
|
| 273 |
-
# lab
|
| 274 |
-
"max_saved_runs": None if role
|
| 275 |
},
|
| 276 |
}
|
| 277 |
return {
|
|
@@ -287,6 +290,26 @@ def auth_me(
|
|
| 287 |
}
|
| 288 |
|
| 289 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
@app.post("/api/auth/register", status_code=201)
|
| 291 |
def register(body: RegisterIn, response: Response, db: Session = Depends(get_db)):
|
| 292 |
email = body.email.strip().lower()
|
|
@@ -296,7 +319,16 @@ def register(body: RegisterIn, response: Response, db: Session = Depends(get_db)
|
|
| 296 |
raise HTTPException(400, f"Password must be at least {auth.MIN_PASSWORD_LEN} characters.")
|
| 297 |
if db.query(User).filter_by(email=email).first():
|
| 298 |
raise HTTPException(409, "An account with this email already exists. Sign in instead.")
|
| 299 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
db.add(user)
|
| 301 |
db.commit()
|
| 302 |
_set_session_cookie(response, user)
|
|
@@ -356,8 +388,10 @@ def google_callback(request: Request, code: str = "", db: Session = Depends(get_
|
|
| 356 |
user = db.query(User).filter_by(email=info["email"]).first()
|
| 357 |
if user is None:
|
| 358 |
# Google-verified account: no local password (password login is refused
|
| 359 |
-
# with a pointer to the Google button).
|
| 360 |
-
|
|
|
|
|
|
|
| 361 |
db.add(user)
|
| 362 |
db.commit()
|
| 363 |
|
|
@@ -677,9 +711,9 @@ def create_job(
|
|
| 677 |
)
|
| 678 |
else:
|
| 679 |
# Signed-in tier: saved-run cap instead of deletion (their data, their
|
| 680 |
-
# call). Lab
|
| 681 |
row = db.get(User, user["id"])
|
| 682 |
-
if (row.role if row else
|
| 683 |
_saved_runs_used(db, user["id"]) >= auth.user_max_saved_runs()
|
| 684 |
):
|
| 685 |
raise HTTPException(
|
|
@@ -840,6 +874,20 @@ def testing_guide():
|
|
| 840 |
return FileResponse(GUIDE_HTML, media_type="text/html")
|
| 841 |
|
| 842 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 843 |
@app.get("/admin", include_in_schema=False)
|
| 844 |
def admin_page():
|
| 845 |
"""Serve the SPA at /admin; the frontend renders the admin view there
|
|
|
|
| 13 |
import os
|
| 14 |
import uuid
|
| 15 |
from contextlib import asynccontextmanager
|
| 16 |
+
from datetime import datetime, timezone
|
| 17 |
from pathlib import Path
|
| 18 |
|
| 19 |
from fastapi import Depends, FastAPI, HTTPException, Request, Response, UploadFile
|
|
|
|
| 32 |
from .construct_lib import sync_library
|
| 33 |
from .db import DATA_DIR, Base, SessionLocal, auto_migrate_sqlite, engine, get_db
|
| 34 |
from .ingest import IngestError, load_corpus, max_rows as corpus_max_rows, suggest_text_column
|
| 35 |
+
from .models import AdminAudit, Construct, Corpus, Job, Project, RoleAssignment, User
|
| 36 |
from .reproducibility import (
|
| 37 |
requirements_filename,
|
| 38 |
requirements_text,
|
|
|
|
| 256 |
):
|
| 257 |
if user:
|
| 258 |
row = db.get(User, user["id"])
|
| 259 |
+
role = auth.normalize_role(row.role if row else None)
|
| 260 |
return {
|
| 261 |
"signed_in": True,
|
| 262 |
"name": user["name"],
|
| 263 |
"email": user["email"],
|
| 264 |
"role": role,
|
| 265 |
+
# effective admin: env allowlist OR staff role (pi/maintainer) -
|
| 266 |
+
# drives the Admin link in the header
|
| 267 |
+
"is_admin": auth.is_admin(user["email"]) or auth.role_is_staff(role),
|
| 268 |
# max_rows is NOT unlimited for signed-in users: CCR_MAX_ROWS is a
|
| 269 |
# global ingest ceiling and is usually the limit that actually
|
| 270 |
# binds (deployed instances set it well below the byte ceiling).
|
|
|
|
| 273 |
"limits": {"max_bytes": max_upload_bytes(), "max_rows": corpus_max_rows()},
|
| 274 |
"usage": {
|
| 275 |
"saved_runs": _saved_runs_used(db, user["id"]),
|
| 276 |
+
# lab members and above: unlimited saved runs (admin-granted)
|
| 277 |
+
"max_saved_runs": None if auth.role_unlimited(role) else auth.user_max_saved_runs(),
|
| 278 |
},
|
| 279 |
}
|
| 280 |
return {
|
|
|
|
| 290 |
}
|
| 291 |
|
| 292 |
|
| 293 |
+
def _initial_role(db: Session, email: str, invite_token: str | None = None) -> str:
|
| 294 |
+
"""Tier a brand-new account lands at. Precedence: an email-bound
|
| 295 |
+
pre-assignment (may carry staff roles - the 'credentials before first
|
| 296 |
+
sign-in' case) beats an invite link (bearer token, external/lab only)
|
| 297 |
+
beats the external default. Claiming is recorded in the audit trail."""
|
| 298 |
+
pre = db.query(RoleAssignment).filter_by(email=email).first()
|
| 299 |
+
if pre is not None and not pre.claimed_at:
|
| 300 |
+
pre.claimed_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
| 301 |
+
role = auth.normalize_role(pre.role)
|
| 302 |
+
db.add(AdminAudit(actor_email=email, action="role_claimed", target=email,
|
| 303 |
+
detail=f"pre-assigned {role} by {pre.assigned_by}"))
|
| 304 |
+
return role
|
| 305 |
+
invited = auth.verify_invite_token(invite_token)
|
| 306 |
+
if invited:
|
| 307 |
+
db.add(AdminAudit(actor_email=email, action="invite_redeemed",
|
| 308 |
+
target=email, detail=invited))
|
| 309 |
+
return invited
|
| 310 |
+
return "external"
|
| 311 |
+
|
| 312 |
+
|
| 313 |
@app.post("/api/auth/register", status_code=201)
|
| 314 |
def register(body: RegisterIn, response: Response, db: Session = Depends(get_db)):
|
| 315 |
email = body.email.strip().lower()
|
|
|
|
| 319 |
raise HTTPException(400, f"Password must be at least {auth.MIN_PASSWORD_LEN} characters.")
|
| 320 |
if db.query(User).filter_by(email=email).first():
|
| 321 |
raise HTTPException(409, "An account with this email already exists. Sign in instead.")
|
| 322 |
+
# A dead invite link should say so, not silently demote to external -
|
| 323 |
+
# unless a pre-assignment covers the email anyway.
|
| 324 |
+
has_preassignment = db.query(RoleAssignment).filter_by(email=email).first() is not None
|
| 325 |
+
if body.invite_token and not auth.verify_invite_token(body.invite_token) and not has_preassignment:
|
| 326 |
+
raise HTTPException(400, "This invite link is invalid or has expired. Ask for a new one.")
|
| 327 |
+
user = User(
|
| 328 |
+
email=email, name=body.name.strip(),
|
| 329 |
+
password_hash=auth.hash_password(body.password),
|
| 330 |
+
role=_initial_role(db, email, body.invite_token),
|
| 331 |
+
)
|
| 332 |
db.add(user)
|
| 333 |
db.commit()
|
| 334 |
_set_session_cookie(response, user)
|
|
|
|
| 388 |
user = db.query(User).filter_by(email=info["email"]).first()
|
| 389 |
if user is None:
|
| 390 |
# Google-verified account: no local password (password login is refused
|
| 391 |
+
# with a pointer to the Google button). Pre-assigned roles apply here
|
| 392 |
+
# too - the "credentials before first sign-in" path works either way.
|
| 393 |
+
user = User(email=info["email"], name=info["name"], password_hash="",
|
| 394 |
+
role=_initial_role(db, info["email"].strip().lower()))
|
| 395 |
db.add(user)
|
| 396 |
db.commit()
|
| 397 |
|
|
|
|
| 711 |
)
|
| 712 |
else:
|
| 713 |
# Signed-in tier: saved-run cap instead of deletion (their data, their
|
| 714 |
+
# call). Lab members and above (admin-granted roles) are uncapped.
|
| 715 |
row = db.get(User, user["id"])
|
| 716 |
+
if not auth.role_unlimited(row.role if row else None) and (
|
| 717 |
_saved_runs_used(db, user["id"]) >= auth.user_max_saved_runs()
|
| 718 |
):
|
| 719 |
raise HTTPException(
|
|
|
|
| 874 |
return FileResponse(GUIDE_HTML, media_type="text/html")
|
| 875 |
|
| 876 |
|
| 877 |
+
# /product: the under-the-hood companion to /guide - access model (tiers,
|
| 878 |
+
# invites, pre-assignments, audit), architecture, data flow, retention.
|
| 879 |
+
# The guide stays a how-to-use-and-test document; this page holds the
|
| 880 |
+
# product/architecture detail (split requested 2026-07-22).
|
| 881 |
+
PRODUCT_HTML = Path(__file__).resolve().parent / "product.html"
|
| 882 |
+
|
| 883 |
+
|
| 884 |
+
@app.get("/product", include_in_schema=False)
|
| 885 |
+
def product_page():
|
| 886 |
+
if not PRODUCT_HTML.exists():
|
| 887 |
+
raise HTTPException(404, "Product page not available on this instance.")
|
| 888 |
+
return FileResponse(PRODUCT_HTML, media_type="text/html")
|
| 889 |
+
|
| 890 |
+
|
| 891 |
@app.get("/admin", include_in_schema=False)
|
| 892 |
def admin_page():
|
| 893 |
"""Serve the SPA at /admin; the frontend renders the admin view there
|
|
@@ -29,12 +29,51 @@ 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 |
-
role: Mapped[str] = mapped_column(String(16), default="
|
| 33 |
-
#
|
| 34 |
-
#
|
|
|
|
|
|
|
|
|
|
| 35 |
created_at: Mapped[str] = mapped_column(String(32), default=_now)
|
| 36 |
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
class Project(Base):
|
| 39 |
__tablename__ = "projects"
|
| 40 |
|
|
|
|
| 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="external")
|
| 33 |
+
# external | lab | maintainer | pi (auth.ROLES; legacy "member" reads as
|
| 34 |
+
# external). lab+ = unlimited saved runs; maintainer/pi also get /admin.
|
| 35 |
+
# Staff roles are grantable only by ADMIN_EMAILS env-allowlisted admins
|
| 36 |
+
# (admin.py guards), so the API cannot self-escalate; the env allowlist
|
| 37 |
+
# remains the bootstrap and break-glass admin path.
|
| 38 |
created_at: Mapped[str] = mapped_column(String(32), default=_now)
|
| 39 |
|
| 40 |
|
| 41 |
+
class RoleAssignment(Base):
|
| 42 |
+
"""Pre-provisioned access: a role bound to an email BEFORE the account
|
| 43 |
+
exists (PI request 2026-07-22 - e.g. an external collaborator who must
|
| 44 |
+
land with full credentials on first sign-in, Google or password).
|
| 45 |
+
|
| 46 |
+
Unlike invite links (bearer tokens, external/lab only), assignments are
|
| 47 |
+
email-bound and may carry staff roles - so creating one for pi/maintainer
|
| 48 |
+
requires escalation rights. Claimed rows are kept as history."""
|
| 49 |
+
|
| 50 |
+
__tablename__ = "role_assignments"
|
| 51 |
+
|
| 52 |
+
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_uuid)
|
| 53 |
+
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
| 54 |
+
role: Mapped[str] = mapped_column(String(16))
|
| 55 |
+
assigned_by: Mapped[str] = mapped_column(String(255), default="")
|
| 56 |
+
created_at: Mapped[str] = mapped_column(String(32), default=_now)
|
| 57 |
+
claimed_at: Mapped[str] = mapped_column(String(32), default="") # "" = pending
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class AdminAudit(Base):
|
| 61 |
+
"""Append-only trail of admin actions (who did what to whom, when).
|
| 62 |
+
|
| 63 |
+
What makes multiple admins trustworthy: role grants, password resets,
|
| 64 |
+
deletions, invites, requeues, and verification changes all land here.
|
| 65 |
+
Never updated or deleted from the app."""
|
| 66 |
+
|
| 67 |
+
__tablename__ = "admin_audit"
|
| 68 |
+
|
| 69 |
+
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_uuid)
|
| 70 |
+
at: Mapped[str] = mapped_column(String(32), default=_now)
|
| 71 |
+
actor_email: Mapped[str] = mapped_column(String(255))
|
| 72 |
+
action: Mapped[str] = mapped_column(String(40)) # e.g. set_role, invite_created
|
| 73 |
+
target: Mapped[str] = mapped_column(String(300), default="") # email/name acted on
|
| 74 |
+
detail: Mapped[str] = mapped_column(Text, default="")
|
| 75 |
+
|
| 76 |
+
|
| 77 |
class Project(Base):
|
| 78 |
__tablename__ = "projects"
|
| 79 |
|
|
@@ -0,0 +1,355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>CCR Platform - Product & Architecture</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--bg: #ffffff; --fg: #1a1a1a; --muted: #666; --border: #ddd;
|
| 10 |
+
--accent: #2456a6; --card: #f7f7f8; --amber-bg: #fff7e0; --amber-border: #e0b84d;
|
| 11 |
+
--code-bg: #f0f0f2;
|
| 12 |
+
}
|
| 13 |
+
@media (prefers-color-scheme: dark) {
|
| 14 |
+
:root {
|
| 15 |
+
--bg: #16181c; --fg: #e6e6e6; --muted: #9a9a9a; --border: #3a3d44;
|
| 16 |
+
--accent: #7aa7e8; --card: #1f2228; --amber-bg: #2e2810; --amber-border: #8a6d1f;
|
| 17 |
+
--code-bg: #24272e;
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
* { box-sizing: border-box; }
|
| 21 |
+
body {
|
| 22 |
+
margin: 0; background: var(--bg); color: var(--fg);
|
| 23 |
+
font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
| 24 |
+
}
|
| 25 |
+
main { max-width: 860px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
| 26 |
+
h1 { font-size: 1.7rem; margin: 0 0 .25rem; }
|
| 27 |
+
h2 { font-size: 1.25rem; margin: 2.2rem 0 .6rem; padding-top: .6rem; border-top: 1px solid var(--border); }
|
| 28 |
+
h3 { font-size: 1.02rem; margin: 1.2rem 0 .4rem; }
|
| 29 |
+
p, li { color: var(--fg); }
|
| 30 |
+
.sub { color: var(--muted); margin: 0 0 1.2rem; }
|
| 31 |
+
a { color: var(--accent); }
|
| 32 |
+
code { background: var(--code-bg); padding: .1em .35em; border-radius: 4px; font-size: .9em; }
|
| 33 |
+
.note {
|
| 34 |
+
background: var(--amber-bg); border: 1px solid var(--amber-border);
|
| 35 |
+
border-radius: 8px; padding: .8rem 1rem; margin: 1rem 0;
|
| 36 |
+
}
|
| 37 |
+
.note b { display: block; margin-bottom: .25rem; }
|
| 38 |
+
.tablewrap { overflow-x: auto; }
|
| 39 |
+
table { border-collapse: collapse; width: 100%; margin: .8rem 0; font-size: .93rem; }
|
| 40 |
+
th, td { border: 1px solid var(--border); padding: .45rem .6rem; text-align: left; vertical-align: top; }
|
| 41 |
+
th { background: var(--card); }
|
| 42 |
+
ol li, ul li { margin: .3rem 0; }
|
| 43 |
+
.top { position: fixed; right: 1rem; bottom: 1rem; background: var(--card);
|
| 44 |
+
border: 1px solid var(--border); border-radius: 8px; padding: .4rem .7rem;
|
| 45 |
+
text-decoration: none; font-size: .85rem; }
|
| 46 |
+
figure { margin: 1rem 0 1.4rem; }
|
| 47 |
+
figure svg { width: 100%; height: auto; border: 1px solid var(--border);
|
| 48 |
+
border-radius: 8px; background: var(--card); }
|
| 49 |
+
figcaption { color: var(--muted); font-size: .85rem; margin-top: .4rem; }
|
| 50 |
+
.box { fill: var(--bg); stroke: var(--border); stroke-width: 1.5; }
|
| 51 |
+
.box-accent { fill: none; stroke: var(--accent); stroke-width: 2; }
|
| 52 |
+
.lbl { fill: var(--fg); font: 600 13px sans-serif; }
|
| 53 |
+
.lbl-sm { fill: var(--muted); font: 11px sans-serif; }
|
| 54 |
+
.flow { stroke: var(--muted); stroke-width: 1.5; fill: none; marker-end: url(#arw); }
|
| 55 |
+
h2[id], h3[id] { scroll-margin-top: 1rem; }
|
| 56 |
+
.anchor {
|
| 57 |
+
opacity: 0; text-decoration: none; color: var(--muted); font-weight: 400;
|
| 58 |
+
margin-left: .4rem; cursor: pointer; transition: opacity .12s;
|
| 59 |
+
}
|
| 60 |
+
h2:hover .anchor, h3:hover .anchor, .anchor:focus { opacity: .7; }
|
| 61 |
+
.anchor:hover { opacity: 1; color: var(--accent); }
|
| 62 |
+
:target { animation: flash 1.4s ease-out; }
|
| 63 |
+
@keyframes flash {
|
| 64 |
+
from { background: var(--amber-bg); }
|
| 65 |
+
to { background: transparent; }
|
| 66 |
+
}
|
| 67 |
+
.toast {
|
| 68 |
+
position: fixed; left: 50%; bottom: 1.5rem; transform: translateX(-50%);
|
| 69 |
+
background: var(--accent); color: #fff; padding: .5rem .9rem; border-radius: 8px;
|
| 70 |
+
font-size: .85rem; opacity: 0; pointer-events: none; transition: opacity .2s;
|
| 71 |
+
}
|
| 72 |
+
.toast.show { opacity: 1; }
|
| 73 |
+
.yes { color: var(--accent); font-weight: 600; }
|
| 74 |
+
.no { color: var(--muted); }
|
| 75 |
+
@media (prefers-reduced-motion: reduce) {
|
| 76 |
+
:target { animation: none; }
|
| 77 |
+
.anchor { transition: none; }
|
| 78 |
+
}
|
| 79 |
+
</style>
|
| 80 |
+
</head>
|
| 81 |
+
<body>
|
| 82 |
+
<main>
|
| 83 |
+
|
| 84 |
+
<h1>CCR Platform - Product & Architecture</h1>
|
| 85 |
+
<p class="sub">The under-the-hood companion to the <a href="/guide">testing guide</a>:
|
| 86 |
+
who can do what, how access is governed, what runs where, and the engineering
|
| 87 |
+
decisions behind it. The guide tells you how to <em>use</em> the platform; this
|
| 88 |
+
page tells you how it <em>works</em>.</p>
|
| 89 |
+
|
| 90 |
+
<h2 id="access">1. Access model: four tiers + break-glass</h2>
|
| 91 |
+
<p>Every account has one of four roles. New sign-ups land as <b>external user</b>
|
| 92 |
+
unless an invite link or a pre-assigned role says otherwise (see
|
| 93 |
+
<a href="#prelogin">§2</a>). Roles are managed entirely in the app, on the
|
| 94 |
+
<code>/admin</code> page - no server configuration involved.</p>
|
| 95 |
+
|
| 96 |
+
<figure>
|
| 97 |
+
<svg viewBox="0 0 760 330" xmlns="http://www.w3.org/2000/svg" role="img"
|
| 98 |
+
aria-label="Access model: env allowlist and PI hold escalation rights over maintainer, who manages lab members and external users. Every action is audited.">
|
| 99 |
+
<defs>
|
| 100 |
+
<marker id="arw" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
|
| 101 |
+
<path d="M0,0 L7,3 L0,6 Z" fill="var(--muted)"/>
|
| 102 |
+
</marker>
|
| 103 |
+
</defs>
|
| 104 |
+
<!-- top row: escalation holders -->
|
| 105 |
+
<rect class="box" x="40" y="20" width="230" height="64" rx="8" stroke-dasharray="5,4"/>
|
| 106 |
+
<text class="lbl" x="155" y="44" text-anchor="middle">ADMIN_EMAILS (env)</text>
|
| 107 |
+
<text class="lbl-sm" x="155" y="62" text-anchor="middle">bootstrap + break-glass only</text>
|
| 108 |
+
<rect class="box-accent" x="330" y="20" width="180" height="64" rx="8"/>
|
| 109 |
+
<text class="lbl" x="420" y="44" text-anchor="middle">PI</text>
|
| 110 |
+
<text class="lbl-sm" x="420" y="62" text-anchor="middle">runs the team from /admin</text>
|
| 111 |
+
<rect class="box" x="560" y="20" width="170" height="64" rx="8"/>
|
| 112 |
+
<text class="lbl" x="645" y="44" text-anchor="middle">Audit trail</text>
|
| 113 |
+
<text class="lbl-sm" x="645" y="62" text-anchor="middle">every action recorded</text>
|
| 114 |
+
<!-- escalation arrows -->
|
| 115 |
+
<path class="flow" d="M155,84 L155,120 L330,150"/>
|
| 116 |
+
<path class="flow" d="M420,84 L420,148"/>
|
| 117 |
+
<text class="lbl-sm" x="440" y="115">grant / revoke any role,</text>
|
| 118 |
+
<text class="lbl-sm" x="440" y="130">act on staff accounts</text>
|
| 119 |
+
<!-- maintainer -->
|
| 120 |
+
<rect class="box-accent" x="330" y="150" width="180" height="64" rx="8"/>
|
| 121 |
+
<text class="lbl" x="420" y="174" text-anchor="middle">Maintainer</text>
|
| 122 |
+
<text class="lbl-sm" x="420" y="192" text-anchor="middle">operations + construct verification</text>
|
| 123 |
+
<!-- maintainer arrows -->
|
| 124 |
+
<path class="flow" d="M420,214 L420,248"/>
|
| 125 |
+
<text class="lbl-sm" x="440" y="238">manage lab / external, invites,</text>
|
| 126 |
+
<text class="lbl-sm" x="440" y="253">resets, requeues</text>
|
| 127 |
+
<!-- bottom row -->
|
| 128 |
+
<rect class="box" x="180" y="250" width="180" height="60" rx="8"/>
|
| 129 |
+
<text class="lbl" x="270" y="274" text-anchor="middle">Lab member</text>
|
| 130 |
+
<text class="lbl-sm" x="270" y="292" text-anchor="middle">unlimited saved runs</text>
|
| 131 |
+
<rect class="box" x="400" y="250" width="180" height="60" rx="8"/>
|
| 132 |
+
<text class="lbl" x="490" y="274" text-anchor="middle">External user</text>
|
| 133 |
+
<text class="lbl-sm" x="490" y="292" text-anchor="middle">saved-run cap (15)</text>
|
| 134 |
+
</svg>
|
| 135 |
+
<figcaption>Escalation flows top-down only: PIs (and the env allowlist, as recovery)
|
| 136 |
+
manage staff; maintainers manage members; nobody can raise their own role. The
|
| 137 |
+
audit trail sees everything.</figcaption>
|
| 138 |
+
</figure>
|
| 139 |
+
|
| 140 |
+
<h3 id="matrix">Capability matrix</h3>
|
| 141 |
+
<div class="tablewrap">
|
| 142 |
+
<table>
|
| 143 |
+
<tr><th>Capability</th><th>Signed out</th><th>External user</th><th>Lab member</th><th>Maintainer</th><th>PI</th></tr>
|
| 144 |
+
<tr><td>Run analyses</td><td>3/day, data deleted after</td><td class="yes">✓</td><td class="yes">✓</td><td class="yes">✓</td><td class="yes">✓</td></tr>
|
| 145 |
+
<tr><td>Saved runs</td><td class="no">none kept</td><td>15</td><td class="yes">unlimited</td><td class="yes">unlimited</td><td class="yes">unlimited</td></tr>
|
| 146 |
+
<tr><td>/admin: manage lab & external users, password resets, requeue failed runs, invites, pre-assignments</td><td class="no">-</td><td class="no">-</td><td class="no">-</td><td class="yes">✓</td><td class="yes">✓</td></tr>
|
| 147 |
+
<tr><td>Construct verification (mark scales verified)</td><td class="no">-</td><td class="no">-</td><td class="no">-</td><td class="yes">✓ their job</td><td class="no">queue visible, read-only</td></tr>
|
| 148 |
+
<tr><td>Grant/revoke PI & maintainer; act on staff accounts</td><td class="no">-</td><td class="no">-</td><td class="no">-</td><td class="no">-</td><td class="yes">✓</td></tr>
|
| 149 |
+
<tr><td>Audit trail (who did what, when)</td><td class="no">-</td><td class="no">-</td><td class="no">-</td><td class="no">-</td><td class="yes">✓</td></tr>
|
| 150 |
+
</table>
|
| 151 |
+
</div>
|
| 152 |
+
<p>Two deliberate asymmetries: <b>verification belongs to maintainers</b> - the
|
| 153 |
+
trail then names the responsible RA, not whichever admin clicked - while
|
| 154 |
+
<b>oversight belongs to the PI</b>, who sees everything (including the audit
|
| 155 |
+
trail) but delegates the hands-on QA. And <b>nobody can change their own
|
| 156 |
+
role</b>, so no session can quietly climb the ladder.</p>
|
| 157 |
+
|
| 158 |
+
<div class="note">
|
| 159 |
+
<b>Break-glass: the ADMIN_EMAILS environment variable</b>
|
| 160 |
+
A short server-side allowlist holds the same powers as the PI role. It exists
|
| 161 |
+
for exactly two reasons: seeding the very first PI (someone has to grant the
|
| 162 |
+
first role) and recovering a locked-out lab. Day-to-day it should be one
|
| 163 |
+
entry - the developer - and everything else lives in the app.
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
<h2 id="prelogin">2. Access before first sign-in</h2>
|
| 167 |
+
<p>Three ways someone has the right access <em>before</em> they ever log in,
|
| 168 |
+
in increasing order of privilege:</p>
|
| 169 |
+
<ol>
|
| 170 |
+
<li><b>Anonymous use</b> - no account at all: 3 runs/day, small files, uploads
|
| 171 |
+
deleted right after analysis. The zero-commitment trial tier.</li>
|
| 172 |
+
<li><b>Invite links</b> - a signed URL an admin copies from /admin into Slack
|
| 173 |
+
or an email. Whoever registers through it lands as a <b>lab member</b> (or
|
| 174 |
+
external user). Bearer-style: anyone with the link can use it, so it only
|
| 175 |
+
carries non-staff roles and expires after 7 days. It cannot be revoked
|
| 176 |
+
early (it is stateless by design); creation and every redemption are audited.</li>
|
| 177 |
+
<li><b>Pre-assigned roles</b> - a role bound to a <em>specific email</em>
|
| 178 |
+
before the account exists. When that person first signs in - password or
|
| 179 |
+
Google - they land at the assigned tier, staff roles included. This is how
|
| 180 |
+
an external collaborator gets full credentials on day one: pre-assign
|
| 181 |
+
<code>maintainer</code> to their email, send them the URL, done. Staff
|
| 182 |
+
pre-assignments require PI (or break-glass) rights, are listed as pending
|
| 183 |
+
until claimed, and can be removed any time before the claim.</li>
|
| 184 |
+
</ol>
|
| 185 |
+
|
| 186 |
+
<h2 id="architecture">3. How it works (architecture & data flow)</h2>
|
| 187 |
+
<p>The whole system is deliberately simple: one application, a few managed
|
| 188 |
+
services around it, and no third-party AI APIs — the embedding models run on
|
| 189 |
+
our own server, so uploaded text never leaves the platform.</p>
|
| 190 |
+
|
| 191 |
+
<h3>What runs where</h3>
|
| 192 |
+
<figure>
|
| 193 |
+
<svg viewBox="0 0 760 300" xmlns="http://www.w3.org/2000/svg" role="img"
|
| 194 |
+
aria-label="Architecture: browser talks to one app container, which uses Postgres, R2, and Supabase/Google auth.">
|
| 195 |
+
<defs>
|
| 196 |
+
<marker id="arw3" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
|
| 197 |
+
<path d="M0,0 L7,3 L0,6 Z" fill="var(--muted)"/>
|
| 198 |
+
</marker>
|
| 199 |
+
</defs>
|
| 200 |
+
<!-- browser -->
|
| 201 |
+
<rect class="box" x="20" y="120" width="150" height="60" rx="8"/>
|
| 202 |
+
<text class="lbl" x="95" y="147" text-anchor="middle">Your browser</text>
|
| 203 |
+
<text class="lbl-sm" x="95" y="165" text-anchor="middle">the web interface</text>
|
| 204 |
+
<!-- app container -->
|
| 205 |
+
<rect class="box-accent" x="270" y="90" width="220" height="120" rx="10"/>
|
| 206 |
+
<text class="lbl" x="380" y="115" text-anchor="middle">One app container</text>
|
| 207 |
+
<text class="lbl-sm" x="380" y="135" text-anchor="middle">API + interface (FastAPI/React)</text>
|
| 208 |
+
<text class="lbl-sm" x="380" y="152" text-anchor="middle">embedding models run here</text>
|
| 209 |
+
<text class="lbl-sm" x="380" y="169" text-anchor="middle">(MiniLM, E5) — no external AI</text>
|
| 210 |
+
<text class="lbl-sm" x="380" y="190" text-anchor="middle">hosted on Hugging Face</text>
|
| 211 |
+
<!-- stores -->
|
| 212 |
+
<rect class="box" x="590" y="30" width="150" height="52" rx="8"/>
|
| 213 |
+
<text class="lbl" x="665" y="52" text-anchor="middle">Postgres</text>
|
| 214 |
+
<text class="lbl-sm" x="665" y="69" text-anchor="middle">records (Supabase)</text>
|
| 215 |
+
<rect class="box" x="590" y="124" width="150" height="52" rx="8"/>
|
| 216 |
+
<text class="lbl" x="665" y="146" text-anchor="middle">Object storage</text>
|
| 217 |
+
<text class="lbl-sm" x="665" y="163" text-anchor="middle">files (Cloudflare R2)</text>
|
| 218 |
+
<rect class="box" x="590" y="218" width="150" height="52" rx="8"/>
|
| 219 |
+
<text class="lbl" x="665" y="240" text-anchor="middle">Sign-in</text>
|
| 220 |
+
<text class="lbl-sm" x="665" y="257" text-anchor="middle">Google / Supabase</text>
|
| 221 |
+
<!-- arrows -->
|
| 222 |
+
<path class="flow" style="marker-end:url(#arw3)" d="M170,150 L268,150"/>
|
| 223 |
+
<path class="flow" style="marker-end:url(#arw3)" d="M492,140 L588,58"/>
|
| 224 |
+
<path class="flow" style="marker-end:url(#arw3)" d="M492,150 L588,150"/>
|
| 225 |
+
<path class="flow" style="marker-end:url(#arw3)" d="M492,162 L588,244"/>
|
| 226 |
+
</svg>
|
| 227 |
+
<figcaption>The interface, the API, and the embedding models all live in one
|
| 228 |
+
container. Around it: a persistent database for records, object storage for
|
| 229 |
+
uploaded files, and Google/Supabase for sign-in. Every piece is on a free tier.</figcaption>
|
| 230 |
+
</figure>
|
| 231 |
+
|
| 232 |
+
<h3>What happens during a run</h3>
|
| 233 |
+
<figure>
|
| 234 |
+
<svg viewBox="0 0 760 190" xmlns="http://www.w3.org/2000/svg" role="img"
|
| 235 |
+
aria-label="Data flow: upload, quality checks, embed items and texts, cosine similarity, scores, results and exports.">
|
| 236 |
+
<defs>
|
| 237 |
+
<marker id="arw2" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
|
| 238 |
+
<path d="M0,0 L7,3 L0,6 Z" fill="var(--muted)"/>
|
| 239 |
+
</marker>
|
| 240 |
+
</defs>
|
| 241 |
+
<g>
|
| 242 |
+
<rect class="box" x="15" y="30" width="120" height="54" rx="8"/>
|
| 243 |
+
<text class="lbl-sm" x="75" y="52" text-anchor="middle">1. Upload</text>
|
| 244 |
+
<text class="lbl-sm" x="75" y="68" text-anchor="middle">corpus + construct</text>
|
| 245 |
+
|
| 246 |
+
<rect class="box" x="165" y="30" width="120" height="54" rx="8"/>
|
| 247 |
+
<text class="lbl-sm" x="225" y="52" text-anchor="middle">2. Quality checks</text>
|
| 248 |
+
<text class="lbl-sm" x="225" y="68" text-anchor="middle">language, warnings</text>
|
| 249 |
+
|
| 250 |
+
<rect class="box-accent" x="315" y="30" width="130" height="54" rx="8"/>
|
| 251 |
+
<text class="lbl-sm" x="380" y="52" text-anchor="middle">3. Embed both</text>
|
| 252 |
+
<text class="lbl-sm" x="380" y="68" text-anchor="middle">items & texts, one model</text>
|
| 253 |
+
|
| 254 |
+
<rect class="box-accent" x="475" y="30" width="130" height="54" rx="8"/>
|
| 255 |
+
<text class="lbl-sm" x="540" y="52" text-anchor="middle">4. Cosine similarity</text>
|
| 256 |
+
<text class="lbl-sm" x="540" y="68" text-anchor="middle">text × each item</text>
|
| 257 |
+
|
| 258 |
+
<rect class="box" x="635" y="30" width="110" height="54" rx="8"/>
|
| 259 |
+
<text class="lbl-sm" x="690" y="52" text-anchor="middle">5. CCR score</text>
|
| 260 |
+
<text class="lbl-sm" x="690" y="68" text-anchor="middle">mean of items</text>
|
| 261 |
+
|
| 262 |
+
<rect class="box" x="240" y="120" width="280" height="50" rx="8"/>
|
| 263 |
+
<text class="lbl-sm" x="380" y="141" text-anchor="middle">6. Results: distributions, per-item loadings,</text>
|
| 264 |
+
<text class="lbl-sm" x="380" y="157" text-anchor="middle">top/bottom texts + CSV, metadata, repro script</text>
|
| 265 |
+
</g>
|
| 266 |
+
<path class="flow" style="marker-end:url(#arw2)" d="M135,57 L163,57"/>
|
| 267 |
+
<path class="flow" style="marker-end:url(#arw2)" d="M285,57 L313,57"/>
|
| 268 |
+
<path class="flow" style="marker-end:url(#arw2)" d="M445,57 L473,57"/>
|
| 269 |
+
<path class="flow" style="marker-end:url(#arw2)" d="M605,57 L633,57"/>
|
| 270 |
+
<path class="flow" style="marker-end:url(#arw2)" d="M690,84 L690,120 L522,145"/>
|
| 271 |
+
</svg>
|
| 272 |
+
<figcaption>CCR in one line: embed the validated scale items and your texts with
|
| 273 |
+
the <em>same</em> model, then each text's cosine similarity to the items is its
|
| 274 |
+
loading; the mean across items is the CCR score. It is deterministic embeddings
|
| 275 |
+
plus arithmetic — no LLM, no prompting, no randomness.</figcaption>
|
| 276 |
+
</figure>
|
| 277 |
+
|
| 278 |
+
<h3>Where your data lives (and for how long)</h3>
|
| 279 |
+
<ul>
|
| 280 |
+
<li><b>Records</b> (account, projects, run results and summaries) → the Postgres
|
| 281 |
+
database. Persistent, backed up.</li>
|
| 282 |
+
<li><b>Uploaded files</b> → object storage. Signed-in users' files persist;
|
| 283 |
+
anonymous uploads are deleted the moment their analysis finishes.</li>
|
| 284 |
+
<li><b>Anonymous sessions</b> are wiped entirely after 24 hours; nothing you
|
| 285 |
+
upload anonymously is kept.</li>
|
| 286 |
+
<li><b>Reproducibility</b>: every run also records the exact model version, an
|
| 287 |
+
item-wording hash, and package versions, so the downloadable script
|
| 288 |
+
reproduces the numbers on any machine.</li>
|
| 289 |
+
</ul>
|
| 290 |
+
|
| 291 |
+
<h3>A few engineering choices worth knowing</h3>
|
| 292 |
+
<ul>
|
| 293 |
+
<li><b>Same model for items and texts</b>, always — mixing models makes scores
|
| 294 |
+
non-comparable, so the platform records the model on every run and warns you.</li>
|
| 295 |
+
<li><b>MiniLM is the default</b> because it is the reference model in the published
|
| 296 |
+
CCR work; the stronger E5 models are available and clearly labelled.</li>
|
| 297 |
+
<li><b>Re-running a new construct on the same corpus is near-instant</b> — the
|
| 298 |
+
text embeddings are cached and reused (identical every time), so only the
|
| 299 |
+
cheap similarity step repeats.</li>
|
| 300 |
+
<li><b>Warnings never silently "fix" your data</b> — the platform surfaces issues
|
| 301 |
+
(wrong language, truncation, duplicates) and lets you decide.</li>
|
| 302 |
+
<li><b>Admin power is layered, not global</b> — see <a href="#access">§1</a>:
|
| 303 |
+
operations are delegated to maintainers, escalation stays with the PI, and
|
| 304 |
+
one env variable exists purely as the fire escape.</li>
|
| 305 |
+
</ul>
|
| 306 |
+
|
| 307 |
+
<h2 id="feedback">4. Questions?</h2>
|
| 308 |
+
<p>For how-to and testing scenarios, see the <a href="/guide">testing guide</a>.
|
| 309 |
+
Anything unclear on this page - post in the lab's <b>#ccr Slack channel</b> or email
|
| 310 |
+
<a href="mailto:devaanand@umass.edu">devaanand@umass.edu</a>.</p>
|
| 311 |
+
|
| 312 |
+
</main>
|
| 313 |
+
<a class="top" href="#">↑ Top</a>
|
| 314 |
+
<div class="toast" id="toast" role="status" aria-live="polite">Link copied</div>
|
| 315 |
+
<script>
|
| 316 |
+
(function () {
|
| 317 |
+
var toast = document.getElementById('toast'), timer;
|
| 318 |
+
function ping(msg) {
|
| 319 |
+
toast.textContent = msg;
|
| 320 |
+
toast.classList.add('show');
|
| 321 |
+
clearTimeout(timer);
|
| 322 |
+
timer = setTimeout(function () { toast.classList.remove('show'); }, 1600);
|
| 323 |
+
}
|
| 324 |
+
function share(id) {
|
| 325 |
+
if (history.replaceState) history.replaceState(null, '', '#' + id);
|
| 326 |
+
location.hash = id;
|
| 327 |
+
var url = location.href;
|
| 328 |
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
| 329 |
+
navigator.clipboard.writeText(url).then(
|
| 330 |
+
function () { ping('Section link copied'); },
|
| 331 |
+
function () { ping('Link in address bar'); }
|
| 332 |
+
);
|
| 333 |
+
} else {
|
| 334 |
+
ping('Link in address bar');
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
var heads = document.querySelectorAll('h2[id], h3[id]');
|
| 338 |
+
Array.prototype.forEach.call(heads, function (h) {
|
| 339 |
+
var a = document.createElement('a');
|
| 340 |
+
a.className = 'anchor';
|
| 341 |
+
a.href = '#' + h.id;
|
| 342 |
+
a.textContent = '#';
|
| 343 |
+
a.setAttribute('aria-label', 'Copy link to this section');
|
| 344 |
+
a.addEventListener('click', function (e) { e.preventDefault(); share(h.id); });
|
| 345 |
+
h.appendChild(a);
|
| 346 |
+
h.style.cursor = 'pointer';
|
| 347 |
+
h.addEventListener('click', function (e) {
|
| 348 |
+
if (e.target.tagName === 'A') return;
|
| 349 |
+
share(h.id);
|
| 350 |
+
});
|
| 351 |
+
});
|
| 352 |
+
})();
|
| 353 |
+
</script>
|
| 354 |
+
</body>
|
| 355 |
+
</html>
|
|
@@ -26,6 +26,7 @@ class RegisterIn(BaseModel):
|
|
| 26 |
email: str = Field(min_length=3, max_length=255)
|
| 27 |
password: str = Field(min_length=8, max_length=200)
|
| 28 |
name: str = Field(min_length=1, max_length=120)
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
class LoginIn(BaseModel):
|
|
|
|
| 26 |
email: str = Field(min_length=3, max_length=255)
|
| 27 |
password: str = Field(min_length=8, max_length=200)
|
| 28 |
name: str = Field(min_length=1, max_length=120)
|
| 29 |
+
invite_token: str | None = None # signed invite link payload (auth.py)
|
| 30 |
|
| 31 |
|
| 32 |
class LoginIn(BaseModel):
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -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-DRZ4Mumd.js"></script>
|
| 8 |
<link rel="stylesheet" crossorigin href="/assets/index-CN_FzJfm.css">
|
| 9 |
</head>
|
| 10 |
<body>
|
|
@@ -126,6 +126,70 @@ def _as_admin(client):
|
|
| 126 |
return client
|
| 127 |
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
# --------------------------------------------------------- reset + delete
|
| 130 |
def test_password_reset_issues_working_temp_password(client):
|
| 131 |
register(client, "forgetful@lab.test")
|
|
@@ -228,12 +292,33 @@ def test_failed_job_requeue(client, monkeypatch):
|
|
| 228 |
|
| 229 |
|
| 230 |
# ------------------------------------------------------------ verification
|
| 231 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
sign_in_as(client, ADMIN_EMAIL, "Admin")
|
| 233 |
queue = client.get("/api/admin/constructs?status=needs_verification").json()
|
| 234 |
assert len(queue) > 0
|
| 235 |
target = queue[0]
|
| 236 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
resp = client.post(
|
| 238 |
f"/api/admin/constructs/{target['id']}/verification", json={"status": "verified"}
|
| 239 |
)
|
|
@@ -244,3 +329,120 @@ def test_verification_queue_marks_verified(client):
|
|
| 244 |
# visible to regular users too (flag disappears in the picker/details)
|
| 245 |
pub = next(c for c in client.get("/api/constructs").json() if c["id"] == target["id"])
|
| 246 |
assert pub["verification_status"] == "verified"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
return client
|
| 127 |
|
| 128 |
|
| 129 |
+
def _user_id(client, email):
|
| 130 |
+
return next(u["id"] for u in client.get("/api/admin/users").json() if u["email"] == email)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# ------------------------------------------- four tiers (PI decision 2026-07-22)
|
| 134 |
+
def test_staff_roles_get_admin_surface_and_unlimited_runs(client, monkeypatch):
|
| 135 |
+
monkeypatch.setenv("CCR_USER_MAX_SAVED_RUNS", "1")
|
| 136 |
+
register(client, "newpi@lab.test", "The PI")
|
| 137 |
+
me = client.get("/api/auth/me").json()
|
| 138 |
+
assert me["role"] == "external" and me["is_admin"] is False # default tier
|
| 139 |
+
assert me["usage"]["max_saved_runs"] == 1
|
| 140 |
+
client.post("/api/auth/logout")
|
| 141 |
+
|
| 142 |
+
_as_admin(client)
|
| 143 |
+
for role in ("pi", "maintainer", "lab", "external"): # all four grantable
|
| 144 |
+
resp = client.post(f"/api/admin/users/{_user_id(client, 'newpi@lab.test')}/role",
|
| 145 |
+
json={"role": role})
|
| 146 |
+
assert resp.status_code == 200 and resp.json()["role"] == role
|
| 147 |
+
client.post(f"/api/admin/users/{_user_id(client, 'newpi@lab.test')}/role",
|
| 148 |
+
json={"role": "pi"})
|
| 149 |
+
assert client.post(f"/api/admin/users/{_user_id(client, 'newpi@lab.test')}/role",
|
| 150 |
+
json={"role": "owner"}).status_code == 400
|
| 151 |
+
overview = client.get("/api/admin/overview").json()
|
| 152 |
+
assert overview["users_by_role"]["pi"] >= 1
|
| 153 |
+
client.post("/api/auth/logout")
|
| 154 |
+
|
| 155 |
+
client.post("/api/auth/login", json={"email": "newpi@lab.test", "password": "password123"})
|
| 156 |
+
me = client.get("/api/auth/me").json()
|
| 157 |
+
assert me["role"] == "pi"
|
| 158 |
+
assert me["is_admin"] is True # staff role grants the admin surface...
|
| 159 |
+
assert me["usage"]["max_saved_runs"] is None # ...and unlimited saved runs
|
| 160 |
+
assert client.get("/api/admin/overview").status_code == 200
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def test_maintainer_cannot_escalate_or_touch_staff(client):
|
| 164 |
+
_as_admin(client)
|
| 165 |
+
for email, role in (("mnt@lab.test", "maintainer"), ("boss@lab.test", "pi"),
|
| 166 |
+
("phd2@lab.test", "external")):
|
| 167 |
+
sign_in_as(client, email)
|
| 168 |
+
client.post("/api/auth/logout")
|
| 169 |
+
_as_admin(client)
|
| 170 |
+
client.post(f"/api/admin/users/{_user_id(client, email)}/role", json={"role": role})
|
| 171 |
+
boss_id, phd_id = _user_id(client, "boss@lab.test"), _user_id(client, "phd2@lab.test")
|
| 172 |
+
client.post("/api/auth/logout")
|
| 173 |
+
|
| 174 |
+
client.post("/api/auth/login", json={"email": "mnt@lab.test", "password": "password123"})
|
| 175 |
+
# operational surface works, and lab/external grants are allowed...
|
| 176 |
+
assert client.get("/api/admin/overview").status_code == 200
|
| 177 |
+
assert client.post(f"/api/admin/users/{phd_id}/role",
|
| 178 |
+
json={"role": "lab"}).status_code == 200
|
| 179 |
+
# ...but staff grants and any action on a staff account are env-admin only
|
| 180 |
+
assert client.post(f"/api/admin/users/{phd_id}/role",
|
| 181 |
+
json={"role": "maintainer"}).status_code == 403
|
| 182 |
+
assert client.post(f"/api/admin/users/{boss_id}/role",
|
| 183 |
+
json={"role": "external"}).status_code == 403
|
| 184 |
+
assert client.post(f"/api/admin/users/{boss_id}/reset-password").status_code == 403
|
| 185 |
+
assert client.delete(f"/api/admin/users/{boss_id}").status_code == 403
|
| 186 |
+
client.post("/api/auth/logout")
|
| 187 |
+
|
| 188 |
+
_as_admin(client) # env admin CAN demote a staff account
|
| 189 |
+
assert client.post(f"/api/admin/users/{boss_id}/role",
|
| 190 |
+
json={"role": "lab"}).status_code == 200
|
| 191 |
+
|
| 192 |
+
|
| 193 |
# --------------------------------------------------------- reset + delete
|
| 194 |
def test_password_reset_issues_working_temp_password(client):
|
| 195 |
register(client, "forgetful@lab.test")
|
|
|
|
| 292 |
|
| 293 |
|
| 294 |
# ------------------------------------------------------------ verification
|
| 295 |
+
def _make_role(client, email, role):
|
| 296 |
+
"""Register (if needed) and set a role, as the env admin; leaves the
|
| 297 |
+
client signed in as that user."""
|
| 298 |
+
sign_in_as(client, email)
|
| 299 |
+
client.post("/api/auth/logout")
|
| 300 |
+
_as_admin(client)
|
| 301 |
+
resp = client.post(f"/api/admin/users/{_user_id(client, email)}/role", json={"role": role})
|
| 302 |
+
assert resp.status_code == 200, resp.json()
|
| 303 |
+
client.post("/api/auth/logout")
|
| 304 |
+
client.post("/api/auth/login", json={"email": email, "password": "password123"})
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def test_verification_is_the_maintainers_job(client):
|
| 308 |
+
"""PI decision 2026-07-22: maintainers verify; PI/admin see the queue
|
| 309 |
+
read-only (the trail then names the responsible RA)."""
|
| 310 |
sign_in_as(client, ADMIN_EMAIL, "Admin")
|
| 311 |
queue = client.get("/api/admin/constructs?status=needs_verification").json()
|
| 312 |
assert len(queue) > 0
|
| 313 |
target = queue[0]
|
| 314 |
|
| 315 |
+
# env admin: queue visible, verification action refused
|
| 316 |
+
resp = client.post(
|
| 317 |
+
f"/api/admin/constructs/{target['id']}/verification", json={"status": "verified"}
|
| 318 |
+
)
|
| 319 |
+
assert resp.status_code == 403 and "maintainer" in resp.json()["detail"]
|
| 320 |
+
|
| 321 |
+
_make_role(client, "ra@lab.test", "maintainer")
|
| 322 |
resp = client.post(
|
| 323 |
f"/api/admin/constructs/{target['id']}/verification", json={"status": "verified"}
|
| 324 |
)
|
|
|
|
| 329 |
# visible to regular users too (flag disappears in the picker/details)
|
| 330 |
pub = next(c for c in client.get("/api/constructs").json() if c["id"] == target["id"])
|
| 331 |
assert pub["verification_status"] == "verified"
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
# ------------------------------------- pre-login access + audit (PI email 2026-07-22)
|
| 335 |
+
def test_preassigned_role_lands_on_first_signin(client):
|
| 336 |
+
"""The Dr. Chen scenario: full credentials bound to an email before the
|
| 337 |
+
account exists; first sign-in (password or Google) claims them."""
|
| 338 |
+
_as_admin(client)
|
| 339 |
+
resp = client.post(
|
| 340 |
+
"/api/admin/role-assignments",
|
| 341 |
+
json={"email": "collaborator@other-lab.edu", "role": "maintainer"},
|
| 342 |
+
)
|
| 343 |
+
assert resp.status_code == 201
|
| 344 |
+
pending = client.get("/api/admin/role-assignments").json()
|
| 345 |
+
entry = next(a for a in pending if a["email"] == "collaborator@other-lab.edu")
|
| 346 |
+
assert entry["role"] == "maintainer" and entry["claimed_at"] is None
|
| 347 |
+
client.post("/api/auth/logout")
|
| 348 |
+
|
| 349 |
+
register(client, "collaborator@other-lab.edu", "Dr. Chen")
|
| 350 |
+
me = client.get("/api/auth/me").json()
|
| 351 |
+
assert me["role"] == "maintainer" and me["is_admin"] is True
|
| 352 |
+
assert me["usage"]["max_saved_runs"] is None
|
| 353 |
+
client.post("/api/auth/logout")
|
| 354 |
+
|
| 355 |
+
_as_admin(client)
|
| 356 |
+
claimed = next(
|
| 357 |
+
a for a in client.get("/api/admin/role-assignments").json()
|
| 358 |
+
if a["email"] == "collaborator@other-lab.edu"
|
| 359 |
+
)
|
| 360 |
+
assert claimed["claimed_at"] is not None
|
| 361 |
+
# existing accounts are managed in the Users table, not via pre-assignment
|
| 362 |
+
resp = client.post(
|
| 363 |
+
"/api/admin/role-assignments",
|
| 364 |
+
json={"email": "collaborator@other-lab.edu", "role": "lab"},
|
| 365 |
+
)
|
| 366 |
+
assert resp.status_code == 409
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def test_maintainer_cannot_preassign_staff(client):
|
| 370 |
+
_make_role(client, "mnt3@lab.test", "maintainer")
|
| 371 |
+
assert client.post(
|
| 372 |
+
"/api/admin/role-assignments",
|
| 373 |
+
json={"email": "someone@new.edu", "role": "pi"},
|
| 374 |
+
).status_code == 403
|
| 375 |
+
assert client.post(
|
| 376 |
+
"/api/admin/role-assignments",
|
| 377 |
+
json={"email": "someone@new.edu", "role": "lab"},
|
| 378 |
+
).status_code == 201
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def test_invite_link_grants_role_on_register(client, monkeypatch):
|
| 382 |
+
_as_admin(client)
|
| 383 |
+
invite = client.post("/api/admin/invites", json={"role": "lab"}).json()
|
| 384 |
+
assert invite["role"] == "lab" and invite["token"]
|
| 385 |
+
# staff can never be invited by bearer link
|
| 386 |
+
assert client.post("/api/admin/invites", json={"role": "maintainer"}).status_code == 400
|
| 387 |
+
client.post("/api/auth/logout")
|
| 388 |
+
|
| 389 |
+
resp = client.post("/api/auth/register", json={
|
| 390 |
+
"email": "invited@lab.test", "password": "password123",
|
| 391 |
+
"name": "Invited", "invite_token": invite["token"],
|
| 392 |
+
})
|
| 393 |
+
assert resp.status_code == 201
|
| 394 |
+
me = client.get("/api/auth/me").json()
|
| 395 |
+
assert me["role"] == "lab" and me["usage"]["max_saved_runs"] is None
|
| 396 |
+
client.post("/api/auth/logout")
|
| 397 |
+
|
| 398 |
+
# dead/garbage tokens refuse registration instead of silently demoting
|
| 399 |
+
resp = client.post("/api/auth/register", json={
|
| 400 |
+
"email": "invited2@lab.test", "password": "password123",
|
| 401 |
+
"name": "Invited2", "invite_token": "garbage.token",
|
| 402 |
+
})
|
| 403 |
+
assert resp.status_code == 400 and "invite" in resp.json()["detail"].lower()
|
| 404 |
+
|
| 405 |
+
monkeypatch.setenv("CCR_INVITE_TTL_DAYS", "-1") # mint an already-expired invite
|
| 406 |
+
_as_admin(client)
|
| 407 |
+
expired = client.post("/api/admin/invites", json={"role": "lab"}).json()
|
| 408 |
+
client.post("/api/auth/logout")
|
| 409 |
+
resp = client.post("/api/auth/register", json={
|
| 410 |
+
"email": "invited3@lab.test", "password": "password123",
|
| 411 |
+
"name": "Invited3", "invite_token": expired["token"],
|
| 412 |
+
})
|
| 413 |
+
assert resp.status_code == 400
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def test_audit_trail_records_and_is_pi_only(client):
|
| 417 |
+
_as_admin(client)
|
| 418 |
+
audit = client.get("/api/admin/audit")
|
| 419 |
+
assert audit.status_code == 200
|
| 420 |
+
actions = {(a["action"], a["target"]) for a in audit.json()}
|
| 421 |
+
assert ("role_preassigned", "collaborator@other-lab.edu") in actions
|
| 422 |
+
assert ("role_claimed", "collaborator@other-lab.edu") in actions
|
| 423 |
+
assert ("invite_redeemed", "invited@lab.test") in actions
|
| 424 |
+
assert any(a[0] == "set_verification" for a in actions)
|
| 425 |
+
client.post("/api/auth/logout")
|
| 426 |
+
|
| 427 |
+
# maintainers work the cards but don't get top-down oversight
|
| 428 |
+
client.post("/api/auth/login", json={"email": "mnt3@lab.test", "password": "password123"})
|
| 429 |
+
assert client.get("/api/admin/audit").status_code == 403
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def test_pi_role_holds_escalation_rights_and_no_self_change(client):
|
| 433 |
+
_make_role(client, "pi2@lab.test", "pi")
|
| 434 |
+
me = client.get("/api/auth/me").json()
|
| 435 |
+
assert me["role"] == "pi" and me["is_admin"] is True
|
| 436 |
+
|
| 437 |
+
sign_in_as(client, "newbie@lab.test")
|
| 438 |
+
client.post("/api/auth/logout")
|
| 439 |
+
client.post("/api/auth/login", json={"email": "pi2@lab.test", "password": "password123"})
|
| 440 |
+
newbie_id = _user_id(client, "newbie@lab.test")
|
| 441 |
+
# a PI-by-role can mint staff and see the audit trail - no env entry needed
|
| 442 |
+
assert client.post(f"/api/admin/users/{newbie_id}/role",
|
| 443 |
+
json={"role": "maintainer"}).status_code == 200
|
| 444 |
+
assert client.get("/api/admin/audit").status_code == 200
|
| 445 |
+
# but nobody, PI included, can change their own role
|
| 446 |
+
my_id = _user_id(client, "pi2@lab.test")
|
| 447 |
+
resp = client.post(f"/api/admin/users/{my_id}/role", json={"role": "external"})
|
| 448 |
+
assert resp.status_code == 400 and "own role" in resp.json()["detail"]
|
|
@@ -78,6 +78,13 @@ def test_health(client):
|
|
| 78 |
assert client.get("/api/health").json()["status"] == "ok"
|
| 79 |
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
def test_seed_constructs_present(client):
|
| 82 |
names = {c["name"] for c in client.get("/api/constructs").json()}
|
| 83 |
assert "Satisfaction with Life" in names
|
|
|
|
| 78 |
assert client.get("/api/health").json()["status"] == "ok"
|
| 79 |
|
| 80 |
|
| 81 |
+
def test_guide_and_product_pages_served(client):
|
| 82 |
+
for path in ("/guide", "/product"):
|
| 83 |
+
resp = client.get(path)
|
| 84 |
+
assert resp.status_code == 200, path
|
| 85 |
+
assert "text/html" in resp.headers["content-type"]
|
| 86 |
+
|
| 87 |
+
|
| 88 |
def test_seed_constructs_present(client):
|
| 89 |
names = {c["name"] for c in client.get("/api/constructs").json()}
|
| 90 |
assert "Satisfaction with Life" in names
|
|
@@ -2,8 +2,18 @@ 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
|
| 6 |
-
// what the admin API returns.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
async function adminFetch(path, options = {}) {
|
| 9 |
const resp = await fetch(path, options);
|
|
@@ -29,15 +39,26 @@ export default function AdminPage({ auth }) {
|
|
| 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(() => {});
|
|
@@ -87,8 +108,11 @@ export default function AdminPage({ auth }) {
|
|
| 87 |
<h3>Overview</h3>
|
| 88 |
{overview ? (
|
| 89 |
<p className="hint">
|
| 90 |
-
<b>{overview.users}</b> accounts (
|
| 91 |
-
{
|
|
|
|
|
|
|
|
|
|
| 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) ·{" "}
|
|
@@ -112,20 +136,21 @@ export default function AdminPage({ auth }) {
|
|
| 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 |
-
<
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
act(() => post(`/api/admin/users/${u.id}/role`, {
|
| 123 |
-
role: u.role === "lab" ? "member" : "lab",
|
| 124 |
-
}))
|
| 125 |
}
|
| 126 |
>
|
| 127 |
-
{
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
{!u.google_only && (
|
| 130 |
<button
|
| 131 |
className="linkish"
|
|
@@ -139,7 +164,7 @@ export default function AdminPage({ auth }) {
|
|
| 139 |
Reset password
|
| 140 |
</button>
|
| 141 |
)}{" "}
|
| 142 |
-
{!u.
|
| 143 |
<button
|
| 144 |
className="linkish danger"
|
| 145 |
onClick={() => {
|
|
@@ -162,6 +187,128 @@ export default function AdminPage({ auth }) {
|
|
| 162 |
</div>
|
| 163 |
</div>
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
{/* Failed runs */}
|
| 166 |
<div className="card">
|
| 167 |
<h3>Failed runs</h3>
|
|
@@ -204,10 +351,11 @@ export default function AdminPage({ auth }) {
|
|
| 204 |
<div className="card">
|
| 205 |
<h3>Construct verification</h3>
|
| 206 |
<p className="hint">
|
| 207 |
-
|
| 208 |
-
against the original publication (cross-reference the
|
| 209 |
-
checklist spreadsheet). Statuses set here are applied
|
| 210 |
-
library files before production.
|
|
|
|
| 211 |
{" "}
|
| 212 |
<button className="linkish" onClick={() => setOnlyUnverified((v) => !v)}>
|
| 213 |
{onlyUnverified ? "Show all" : "Show unverified only"}
|
|
@@ -230,18 +378,20 @@ export default function AdminPage({ auth }) {
|
|
| 230 |
</span>
|
| 231 |
</td>
|
| 232 |
<td>
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
|
|
|
|
|
|
| 245 |
</td>
|
| 246 |
</tr>
|
| 247 |
))}
|
|
|
|
| 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 allowlist or pi/maintainer
|
| 6 |
+
// role); this page just renders what the admin API returns. Actions a
|
| 7 |
+
// maintainer isn't allowed to take (granting staff roles, touching staff
|
| 8 |
+
// accounts) are rejected by the server and surface in the error banner.
|
| 9 |
+
|
| 10 |
+
const ROLES = ["external", "lab", "maintainer", "pi"];
|
| 11 |
+
const ROLE_LABELS = {
|
| 12 |
+
external: "external user",
|
| 13 |
+
lab: "lab member",
|
| 14 |
+
maintainer: "maintainer",
|
| 15 |
+
pi: "PI",
|
| 16 |
+
};
|
| 17 |
|
| 18 |
async function adminFetch(path, options = {}) {
|
| 19 |
const resp = await fetch(path, options);
|
|
|
|
| 39 |
const [users, setUsers] = useState([]);
|
| 40 |
const [failed, setFailed] = useState([]);
|
| 41 |
const [constructs, setConstructs] = useState([]);
|
| 42 |
+
const [assignments, setAssignments] = useState([]);
|
| 43 |
+
const [audit, setAudit] = useState(null); // null = not visible (maintainers)
|
| 44 |
const [onlyUnverified, setOnlyUnverified] = useState(true);
|
| 45 |
+
const [inviteRole, setInviteRole] = useState("lab");
|
| 46 |
+
const [assignEmail, setAssignEmail] = useState("");
|
| 47 |
+
const [assignRole, setAssignRole] = useState("lab");
|
| 48 |
const [error, setError] = useState("");
|
| 49 |
const [notice, setNotice] = useState("");
|
| 50 |
|
| 51 |
+
// Only maintainers can flip verification statuses (the RA workflow);
|
| 52 |
+
// PI/admin see the queue read-only. Enforced server-side too.
|
| 53 |
+
const canVerify = auth?.role === "maintainer";
|
| 54 |
+
|
| 55 |
const reload = useCallback(() => {
|
| 56 |
setError("");
|
| 57 |
adminFetch("/api/admin/overview").then(setOverview).catch((e) => setError(e.message));
|
| 58 |
adminFetch("/api/admin/users").then(setUsers).catch(() => {});
|
| 59 |
adminFetch("/api/admin/jobs/failed").then(setFailed).catch(() => {});
|
| 60 |
+
adminFetch("/api/admin/role-assignments").then(setAssignments).catch(() => {});
|
| 61 |
+
adminFetch("/api/admin/audit").then(setAudit).catch(() => setAudit(null)); // 403 for maintainers
|
| 62 |
adminFetch(
|
| 63 |
"/api/admin/constructs" + (onlyUnverified ? "?status=needs_verification" : "")
|
| 64 |
).then(setConstructs).catch(() => {});
|
|
|
|
| 108 |
<h3>Overview</h3>
|
| 109 |
{overview ? (
|
| 110 |
<p className="hint">
|
| 111 |
+
<b>{overview.users}</b> accounts (
|
| 112 |
+
{ROLES.filter((r) => overview.users_by_role?.[r])
|
| 113 |
+
.map((r) => `${overview.users_by_role[r]} ${ROLE_LABELS[r]}`)
|
| 114 |
+
.join(", ") || "none"}
|
| 115 |
+
; {overview.signups_last_7_days} new this week) · <b>{overview.runs_total}</b>{" "}
|
| 116 |
runs total ({overview.runs_last_7_days} this week
|
| 117 |
{overview.runs_by_status?.failed ? `, ${overview.runs_by_status.failed} failed` : ""}) ·{" "}
|
| 118 |
<b>{overview.projects}</b> projects ({overview.anonymous_projects} anonymous) ·{" "}
|
|
|
|
| 136 |
<tr key={u.id}>
|
| 137 |
<td>{u.email}{u.is_admin ? " ★" : ""}</td>
|
| 138 |
<td>{u.name}</td>
|
|
|
|
|
|
|
|
|
|
| 139 |
<td>
|
| 140 |
+
<select
|
| 141 |
+
value={u.role}
|
| 142 |
+
onChange={(e) =>
|
| 143 |
+
act(() => post(`/api/admin/users/${u.id}/role`, { role: e.target.value }))
|
|
|
|
|
|
|
| 144 |
}
|
| 145 |
>
|
| 146 |
+
{ROLES.map((r) => (
|
| 147 |
+
<option key={r} value={r}>{ROLE_LABELS[r]}</option>
|
| 148 |
+
))}
|
| 149 |
+
</select>
|
| 150 |
+
</td>
|
| 151 |
+
<td>{u.saved_runs}</td>
|
| 152 |
+
<td className="muted small">{u.google_only ? "Google" : "password"}</td>
|
| 153 |
+
<td>
|
| 154 |
{!u.google_only && (
|
| 155 |
<button
|
| 156 |
className="linkish"
|
|
|
|
| 164 |
Reset password
|
| 165 |
</button>
|
| 166 |
)}{" "}
|
| 167 |
+
{!u.env_admin && (
|
| 168 |
<button
|
| 169 |
className="linkish danger"
|
| 170 |
onClick={() => {
|
|
|
|
| 187 |
</div>
|
| 188 |
</div>
|
| 189 |
|
| 190 |
+
{/* Access before sign-in: pre-assigned roles + invite links */}
|
| 191 |
+
<div className="card">
|
| 192 |
+
<h3>Access before sign-in</h3>
|
| 193 |
+
<p className="hint">
|
| 194 |
+
<b>Pre-assign a role to an email</b> (e.g. an external collaborator who
|
| 195 |
+
should land with full credentials): whoever first signs in with that
|
| 196 |
+
email - password or Google - gets the role automatically.
|
| 197 |
+
</p>
|
| 198 |
+
<form
|
| 199 |
+
onSubmit={(e) => {
|
| 200 |
+
e.preventDefault();
|
| 201 |
+
if (!assignEmail.trim()) return;
|
| 202 |
+
act(async () => {
|
| 203 |
+
await post("/api/admin/role-assignments", {
|
| 204 |
+
email: assignEmail.trim(), role: assignRole,
|
| 205 |
+
});
|
| 206 |
+
setAssignEmail("");
|
| 207 |
+
});
|
| 208 |
+
}}
|
| 209 |
+
style={{ display: "flex", gap: ".5rem", flexWrap: "wrap", alignItems: "center" }}
|
| 210 |
+
>
|
| 211 |
+
<input
|
| 212 |
+
type="email"
|
| 213 |
+
placeholder="person@university.edu"
|
| 214 |
+
value={assignEmail}
|
| 215 |
+
onChange={(e) => setAssignEmail(e.target.value)}
|
| 216 |
+
style={{ minWidth: "16rem" }}
|
| 217 |
+
/>
|
| 218 |
+
<select value={assignRole} onChange={(e) => setAssignRole(e.target.value)}>
|
| 219 |
+
{ROLES.map((r) => (
|
| 220 |
+
<option key={r} value={r}>{ROLE_LABELS[r]}</option>
|
| 221 |
+
))}
|
| 222 |
+
</select>
|
| 223 |
+
<button type="submit" disabled={!assignEmail.trim()}>Pre-assign</button>
|
| 224 |
+
</form>
|
| 225 |
+
{assignments.length > 0 && (
|
| 226 |
+
<div className="table-wrap">
|
| 227 |
+
<table className="docs">
|
| 228 |
+
<thead>
|
| 229 |
+
<tr><th>Email</th><th>Role</th><th>By</th><th>Status</th><th /></tr>
|
| 230 |
+
</thead>
|
| 231 |
+
<tbody>
|
| 232 |
+
{assignments.map((a) => (
|
| 233 |
+
<tr key={a.id}>
|
| 234 |
+
<td>{a.email}</td>
|
| 235 |
+
<td>{ROLE_LABELS[a.role] || a.role}</td>
|
| 236 |
+
<td className="muted small">{a.assigned_by}</td>
|
| 237 |
+
<td className="muted small">
|
| 238 |
+
{a.claimed_at ? `claimed ${a.claimed_at.slice(0, 10)}` : "pending"}
|
| 239 |
+
</td>
|
| 240 |
+
<td>
|
| 241 |
+
{!a.claimed_at && (
|
| 242 |
+
<button
|
| 243 |
+
className="linkish danger"
|
| 244 |
+
onClick={() =>
|
| 245 |
+
act(() => adminFetch(`/api/admin/role-assignments/${a.id}`, { method: "DELETE" }))
|
| 246 |
+
}
|
| 247 |
+
>
|
| 248 |
+
Remove
|
| 249 |
+
</button>
|
| 250 |
+
)}
|
| 251 |
+
</td>
|
| 252 |
+
</tr>
|
| 253 |
+
))}
|
| 254 |
+
</tbody>
|
| 255 |
+
</table>
|
| 256 |
+
</div>
|
| 257 |
+
)}
|
| 258 |
+
<p className="hint" style={{ marginTop: "1rem" }}>
|
| 259 |
+
<b>Or create an invite link</b> (anyone with the link; lab member /
|
| 260 |
+
external only - staff is granted per person above): paste it in Slack,
|
| 261 |
+
it expires after a week.
|
| 262 |
+
</p>
|
| 263 |
+
<div style={{ display: "flex", gap: ".5rem", alignItems: "center" }}>
|
| 264 |
+
<select value={inviteRole} onChange={(e) => setInviteRole(e.target.value)}>
|
| 265 |
+
<option value="lab">lab member</option>
|
| 266 |
+
<option value="external">external user</option>
|
| 267 |
+
</select>
|
| 268 |
+
<button
|
| 269 |
+
onClick={() =>
|
| 270 |
+
act(async () => {
|
| 271 |
+
const r = await post("/api/admin/invites", { role: inviteRole });
|
| 272 |
+
const url = `${window.location.origin}/?invite=${encodeURIComponent(r.token)}`;
|
| 273 |
+
try { await navigator.clipboard.writeText(url); } catch { /* show below */ }
|
| 274 |
+
setNotice(`Invite link (${ROLE_LABELS[r.role]}, expires ${r.expires_at}) - copied: ${url}`);
|
| 275 |
+
})
|
| 276 |
+
}
|
| 277 |
+
>
|
| 278 |
+
Create invite link
|
| 279 |
+
</button>
|
| 280 |
+
</div>
|
| 281 |
+
</div>
|
| 282 |
+
|
| 283 |
+
{/* Audit trail - PI/env-admin only (404s/403s hide it for maintainers) */}
|
| 284 |
+
{audit !== null && (
|
| 285 |
+
<div className="card">
|
| 286 |
+
<h3>Audit trail</h3>
|
| 287 |
+
{audit.length === 0 ? (
|
| 288 |
+
<p className="hint">No admin actions recorded yet.</p>
|
| 289 |
+
) : (
|
| 290 |
+
<div className="table-wrap">
|
| 291 |
+
<table className="docs">
|
| 292 |
+
<thead>
|
| 293 |
+
<tr><th>When</th><th>Who</th><th>Action</th><th>Target</th><th>Detail</th></tr>
|
| 294 |
+
</thead>
|
| 295 |
+
<tbody>
|
| 296 |
+
{audit.map((a, i) => (
|
| 297 |
+
<tr key={i}>
|
| 298 |
+
<td className="muted small">{a.at.replace("T", " ").slice(0, 16)}</td>
|
| 299 |
+
<td className="small">{a.actor}</td>
|
| 300 |
+
<td className="small">{a.action.replaceAll("_", " ")}</td>
|
| 301 |
+
<td className="small">{a.target}</td>
|
| 302 |
+
<td className="muted small">{a.detail}</td>
|
| 303 |
+
</tr>
|
| 304 |
+
))}
|
| 305 |
+
</tbody>
|
| 306 |
+
</table>
|
| 307 |
+
</div>
|
| 308 |
+
)}
|
| 309 |
+
</div>
|
| 310 |
+
)}
|
| 311 |
+
|
| 312 |
{/* Failed runs */}
|
| 313 |
<div className="card">
|
| 314 |
<h3>Failed runs</h3>
|
|
|
|
| 351 |
<div className="card">
|
| 352 |
<h3>Construct verification</h3>
|
| 353 |
<p className="hint">
|
| 354 |
+
The maintainer's workflow: mark a scale verified once its wording is
|
| 355 |
+
checked against the original publication (cross-reference the
|
| 356 |
+
verification checklist spreadsheet). Statuses set here are applied
|
| 357 |
+
back to the library files before production.
|
| 358 |
+
{!canVerify && " Your account has read access; verification actions are for maintainers."}
|
| 359 |
{" "}
|
| 360 |
<button className="linkish" onClick={() => setOnlyUnverified((v) => !v)}>
|
| 361 |
{onlyUnverified ? "Show all" : "Show unverified only"}
|
|
|
|
| 378 |
</span>
|
| 379 |
</td>
|
| 380 |
<td>
|
| 381 |
+
{canVerify && (
|
| 382 |
+
<button
|
| 383 |
+
className="linkish"
|
| 384 |
+
onClick={() =>
|
| 385 |
+
act(() => post(`/api/admin/constructs/${c.id}/verification`, {
|
| 386 |
+
status: c.verification_status === "verified"
|
| 387 |
+
? "needs_verification"
|
| 388 |
+
: "verified",
|
| 389 |
+
}))
|
| 390 |
+
}
|
| 391 |
+
>
|
| 392 |
+
{c.verification_status === "verified" ? "Un-verify" : "Mark verified"}
|
| 393 |
+
</button>
|
| 394 |
+
)}
|
| 395 |
</td>
|
| 396 |
</tr>
|
| 397 |
))}
|
|
@@ -55,6 +55,19 @@ export default function App() {
|
|
| 55 |
const [authName, setAuthName] = useState("");
|
| 56 |
const [authError, setAuthError] = useState("");
|
| 57 |
const [authBusy, setAuthBusy] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
const loadProjects = () =>
|
| 60 |
api.listProjects().then(setProjects).catch((e) => setError(e.message));
|
|
@@ -70,6 +83,15 @@ export default function App() {
|
|
| 70 |
setError(`Sign-in problem: ${authFail.replaceAll("-", " ")}.`);
|
| 71 |
window.history.replaceState({}, "", "/");
|
| 72 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
}, []);
|
| 74 |
|
| 75 |
async function handleAuthSubmit(e) {
|
|
@@ -78,7 +100,14 @@ export default function App() {
|
|
| 78 |
setAuthBusy(true);
|
| 79 |
try {
|
| 80 |
if (authMode === "register") {
|
| 81 |
-
await api.register({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
} else {
|
| 83 |
await api.login({ email: authEmail.trim(), password: authPassword });
|
| 84 |
}
|
|
@@ -163,6 +192,12 @@ export default function App() {
|
|
| 163 |
<div className="modal-backdrop" onClick={() => setShowLogin(false)}>
|
| 164 |
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
| 165 |
<h3>{authMode === "register" ? "Create an account" : "Sign in"}</h3>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
<p className="hint">
|
| 167 |
Accounts are free. Signing in lifts the anonymous limits
|
| 168 |
{auth?.limits?.max_rows
|
|
|
|
| 55 |
const [authName, setAuthName] = useState("");
|
| 56 |
const [authError, setAuthError] = useState("");
|
| 57 |
const [authBusy, setAuthBusy] = useState(false);
|
| 58 |
+
const [inviteToken, setInviteToken] = useState("");
|
| 59 |
+
|
| 60 |
+
// Invite links carry their role in the signed payload; decode it for the
|
| 61 |
+
// banner only - the server re-verifies the signature on registration.
|
| 62 |
+
const inviteRole = (() => {
|
| 63 |
+
if (!inviteToken) return null;
|
| 64 |
+
try {
|
| 65 |
+
const data = JSON.parse(atob(inviteToken.split(".")[0].replace(/-/g, "+").replace(/_/g, "/")));
|
| 66 |
+
return { lab: "lab member", external: "external user" }[data.invite] || null;
|
| 67 |
+
} catch {
|
| 68 |
+
return null;
|
| 69 |
+
}
|
| 70 |
+
})();
|
| 71 |
|
| 72 |
const loadProjects = () =>
|
| 73 |
api.listProjects().then(setProjects).catch((e) => setError(e.message));
|
|
|
|
| 83 |
setError(`Sign-in problem: ${authFail.replaceAll("-", " ")}.`);
|
| 84 |
window.history.replaceState({}, "", "/");
|
| 85 |
}
|
| 86 |
+
// Invite link (?invite=TOKEN): open the signup form with the token
|
| 87 |
+
// attached. The URL keeps the token until signup succeeds, so a page
|
| 88 |
+
// refresh doesn't lose the invite.
|
| 89 |
+
const invite = params.get("invite");
|
| 90 |
+
if (invite) {
|
| 91 |
+
setInviteToken(invite);
|
| 92 |
+
setAuthMode("register");
|
| 93 |
+
setShowLogin(true);
|
| 94 |
+
}
|
| 95 |
}, []);
|
| 96 |
|
| 97 |
async function handleAuthSubmit(e) {
|
|
|
|
| 100 |
setAuthBusy(true);
|
| 101 |
try {
|
| 102 |
if (authMode === "register") {
|
| 103 |
+
await api.register({
|
| 104 |
+
email: authEmail.trim(), password: authPassword, name: authName.trim(),
|
| 105 |
+
...(inviteToken ? { invite_token: inviteToken } : {}),
|
| 106 |
+
});
|
| 107 |
+
if (inviteToken) {
|
| 108 |
+
setInviteToken("");
|
| 109 |
+
window.history.replaceState({}, "", "/"); // invite consumed
|
| 110 |
+
}
|
| 111 |
} else {
|
| 112 |
await api.login({ email: authEmail.trim(), password: authPassword });
|
| 113 |
}
|
|
|
|
| 192 |
<div className="modal-backdrop" onClick={() => setShowLogin(false)}>
|
| 193 |
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
| 194 |
<h3>{authMode === "register" ? "Create an account" : "Sign in"}</h3>
|
| 195 |
+
{inviteToken && authMode === "register" && (
|
| 196 |
+
<p className="hint" style={{ fontWeight: 600 }}>
|
| 197 |
+
🎟 You've been invited{inviteRole ? ` as a ${inviteRole}` : ""} - create
|
| 198 |
+
your account below and the access comes with it.
|
| 199 |
+
</p>
|
| 200 |
+
)}
|
| 201 |
<p className="hint">
|
| 202 |
Accounts are free. Signing in lifts the anonymous limits
|
| 203 |
{auth?.limits?.max_rows
|