diff --git a/html/auth-guard.js b/html/auth-guard.js index 6d55683..1fb3f9f 100644 --- a/html/auth-guard.js +++ b/html/auth-guard.js @@ -125,12 +125,40 @@ // Nothing replaces it. Every signed-in page mounts the drawer, so there is no page // left that would need a floating fallback pill. + // ── CR-019: usage ping ─────────────────────────────────────────────────── + // One page_open event per authenticated load, sent from exactly ONE place + // (here) rather than from each page's own script - the shared-chrome lesson + // S4 and the token-drift lesson S5 both taught this codebase the hard way. + // Fire-and-forget: never blocks reveal(), never retries, never surfaces an + // error to the person using the app - a missed usage ping is not something + // anyone here should notice happening. + var TOOL_BY_PAGE = { + 'index.html': 'launcher', + 'work-package-suite.html': 'wizard', + 'wp-creation-index.html': 'creator', + 'field.html': 'field_view', + 'admin.html': 'admin', + 'users.html': 'directory' + }; + function pingUsage() { + var page = (location.pathname.split('/').pop() || 'index.html'); + var tool = TOOL_BY_PAGE[page] || page.replace(/\.html$/, ''); + try { + fetch('/api/usage/ping', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tool: tool }) + }).catch(function () {}); + } catch (e) {} + } + function proceed(user) { clearTimeout(safety); window.WP_USER = user; reveal(); if (window.WP_USER) { window.wpFlags(); // start the feature-flag fetch; pages await it as needed + pingUsage(); try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {} } } diff --git a/server/app.py b/server/app.py index 3df79e3..24f18e8 100644 --- a/server/app.py +++ b/server/app.py @@ -756,6 +756,11 @@ async def okta_callback(request: Request, db: Session = Depends(get_db)): user.failed_attempts = 0 user.locked_until = None user.last_login_at = models.utcnow() + # CR-019: one login event per Okta sign-in, written here rather than + # inferred from session creation elsewhere, so there is exactly one + # source of truth for "did this person sign in" - not one per page load + # afterward (T11.2 covers that separately, as page_open events). + db.add(models.UsageEvent(id=gen_id("uev"), username=user.username, tool="", event="login")) db.commit() db.refresh(user) @@ -3374,6 +3379,29 @@ def create_feedback(body: CommentIn, user: models.User = Depends(auth.get_curren return _save_comment(body, db, user) +# ── CR-019: usage/activity metrics ────────────────────────────────────────── +class UsageIn(BaseModel): + tool: str = "" + project_id: Optional[str] = None + + +@app.post("/api/usage/ping") +def usage_ping(body: UsageIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): + """One usage_events row per authenticated page load. Called exactly once, + from auth-guard.js after wp-auth-ready fires (T11.2) - never duplicated + per page's own script, the same lesson S4's per-page nav already taught + this codebase. `user` comes from the session via get_current_user, never + from anything the client claims - identity here is a server-enforced + fact, matching every other write in this file, not a client-reported one.""" + tool = (body.tool or "").strip()[:40] + db.add(models.UsageEvent( + id=gen_id("uev"), username=user.username, project_id=body.project_id, + tool=tool, event="page_open", + )) + db.commit() + return {"ok": True} + + @app.get("/api/comments") def list_comments( source: Optional[str] = Query(None),