diff --git a/backend/db.py b/backend/db.py index 636ee61..d596f2e 100644 --- a/backend/db.py +++ b/backend/db.py @@ -29,6 +29,17 @@ meta TEXT ); CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind); +CREATE TABLE IF NOT EXISTS accounts ( + account_id TEXT PRIMARY KEY, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS identities ( + provider TEXT NOT NULL, + provider_user_id TEXT NOT NULL, + username TEXT NOT NULL, + account_id TEXT NOT NULL REFERENCES accounts(account_id), + PRIMARY KEY (provider, provider_user_id) +); """ @@ -54,6 +65,14 @@ c.execute("ALTER TABLE results ADD COLUMN superseded INTEGER DEFAULT 0") except sqlite3.OperationalError: pass # column already exists + # migration: account_id column (multi-identity accounts, 2026-10-13) + try: + c.execute("ALTER TABLE results ADD COLUMN account_id TEXT") + except sqlite3.OperationalError: + pass # column already exists + c.execute("CREATE INDEX IF NOT EXISTS idx_results_account" + " ON results(account_id)") + migrate_results_to_accounts(conn=c) def new_result_id() -> str: @@ -70,16 +89,17 @@ def save_result(result_id: str, score: int, percentile: float, bank_version: str, identity: Optional[str], completed_at: str, payload: str, signature: str, answers: dict, - flagged: bool = False, score_json: Optional[str] = None) -> None: + flagged: bool = False, score_json: Optional[str] = None, + account_id: Optional[str] = None) -> None: with _conn() as c: c.execute( "INSERT INTO results (result_id, score, percentile, bank_version," " identity, completed_at, payload, signature, answers, flagged," - " score_json)" - " VALUES (?,?,?,?,?,?,?,?,?,?,?)", + " score_json, account_id)" + " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", (result_id, score, percentile, bank_version, identity, completed_at, payload, signature, json.dumps(answers), - int(flagged), score_json), + int(flagged), score_json, account_id), ) @@ -134,3 +154,124 @@ cur = c.execute("UPDATE results SET identity=? WHERE result_id=?", (identity, result_id)) return cur.rowcount > 0 + +# --- accounts & identities (multi-identity accounts, 2026-10-13) --------------- + +def new_account_id() -> str: + return "a" + secrets.token_hex(8) + + +def create_account(conn: Optional[sqlite3.Connection] = None) -> str: + """Create an account row, return its id.""" + aid = new_account_id() + sql = "INSERT INTO accounts (account_id, created_at) VALUES (?,?)" + args = (aid, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())) + if conn is not None: + conn.execute(sql, args) + return aid + with _conn() as c: + c.execute(sql, args) + return aid + + +def get_identity(provider: str, provider_user_id: str) -> Optional[dict]: + with _conn() as c: + row = c.execute( + "SELECT * FROM identities WHERE provider=? AND provider_user_id=?", + (provider, provider_user_id)).fetchone() + return dict(row) if row else None + + +def create_identity(provider: str, provider_user_id: str, username: str, + account_id: str, + conn: Optional[sqlite3.Connection] = None) -> None: + sql = ("INSERT INTO identities (provider, provider_user_id, username," + " account_id) VALUES (?,?,?,?)") + args = (provider, provider_user_id, username, account_id) + if conn is not None: + conn.execute(sql, args) + return + with _conn() as c: + c.execute(sql, args) + + +def update_identity_username(provider: str, provider_user_id: str, + username: str) -> None: + with _conn() as c: + c.execute("UPDATE identities SET username=? WHERE provider=?" + " AND provider_user_id=?", + (username, provider, provider_user_id)) + + +def account_identities(account_id: str) -> list: + with _conn() as c: + rows = c.execute( + "SELECT provider, username FROM identities WHERE account_id=?" + " ORDER BY provider", (account_id,)).fetchall() + return [dict(r) for r in rows] + + +def _migrate_results_to_accounts(c: sqlite3.Connection) -> int: + rows = c.execute( + "SELECT DISTINCT identity FROM results" + " WHERE identity IS NOT NULL AND identity != ''" + " AND account_id IS NULL").fetchall() + n = 0 + for r in rows: + identity = r["identity"] + if ":" in identity: + provider, uname = identity.split(":", 1) + uname = uname.lstrip("@") + else: + provider, uname = "x", identity + pid = identity # stable unique key (real provider_user_id unknown) + row = c.execute( + "SELECT account_id FROM identities" + " WHERE provider=? AND provider_user_id=?", + (provider, pid)).fetchone() + if row: + account_id = row["account_id"] + else: + account_id = new_account_id() + c.execute("INSERT INTO accounts (account_id, created_at)" + " VALUES (?,?)", + (account_id, + time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))) + c.execute("INSERT INTO identities (provider, provider_user_id," + " username, account_id) VALUES (?,?,?,?)", + (provider, pid, uname, account_id)) + c.execute("UPDATE results SET account_id=? WHERE identity=?" + " AND account_id IS NULL", (account_id, identity)) + n += 1 + return n + + +def migrate_results_to_accounts(conn: Optional[sqlite3.Connection] = None) -> int: + """One-time migration: give every pre-accounts result an account. + + Old rows only carry identity like "x:@user"; provider_user_id is unknown, + so the full identity string is used as a stable unique key. + """ + if conn is not None: + return _migrate_results_to_accounts(conn) + with _conn() as c: + n = _migrate_results_to_accounts(c) + c.commit() + return n + + +def supersede_account(account_id: str) -> int: + """Mark all previous active results of this account as superseded.""" + with _conn() as c: + cur = c.execute( + "UPDATE results SET superseded=1 WHERE account_id=?" + " AND superseded=0", (account_id,)) + return cur.rowcount + + +def active_result_for_account(account_id: str) -> Optional[dict]: + with _conn() as c: + row = c.execute( + "SELECT * FROM results WHERE account_id=? AND superseded=0" + " ORDER BY completed_at DESC LIMIT 1", (account_id,)).fetchone() + return dict(row) if row else None diff --git a/backend/main.py b/backend/main.py index c8e0935..efd1ffa 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,7 +11,7 @@ import time from pathlib import Path -from fastapi import FastAPI, HTTPException, Request +from fastapi import FastAPI, HTTPException, Request, Response from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse from pydantic import BaseModel, Field @@ -49,12 +49,16 @@ def _inject_meta(html: str, title: str, description: str, - image_url: Optional[str] = None) -> str: + image_url: Optional[str] = None, + page_url: Optional[str] = None) -> str: """Server-side OG meta injection for social scrapers.""" esc = lambda s: s.replace("&", "&").replace('"', """).replace("<", "<") meta = f"{esc(title)}\n" meta += f'\n' meta += f'\n' + if page_url: + meta += f'\n' + meta += '\n' if image_url: meta += f'\n' meta += '\n' @@ -89,6 +93,10 @@ return oauth.verify_identity(request.cookies.get(oauth.IDENTITY_COOKIE, "")) +def _session_from(request: Request) -> Optional[dict]: + return oauth.verify_session(request.cookies.get(oauth.SESSION_COOKIE, "")) + + @app.get("/login") def login_page(): """Explicit provider choice: X or Instagram (button disabled until configured).""" @@ -97,8 +105,8 @@ @app.get("/test") def test_page(request: Request): - """Auth-before-test: no valid identity cookie -> explicit provider choice.""" - if _identity_from(request) is None: + """Auth-before-test: no valid session cookie -> explicit provider choice.""" + if _session_from(request) is None: return RedirectResponse("/login", status_code=303) return FileResponse(FRONTEND_DIR / "test.html") @@ -121,7 +129,10 @@ sig = "valid" if valid else "INVALID" desc = (f"Higher than {row['percentile']}% of test takers. " f"Signature {sig}. Verify it yourself.") - return HTMLResponse(_inject_meta(html, title, desc, f"/og/{result_id}.png")) + base = oauth.public_base_url() + return HTMLResponse(_inject_meta(html, title, desc, + f"{base}/og/{result_id}.png", + f"{base}/v/{result_id}")) @app.get("/v/{result_id}") @@ -134,7 +145,10 @@ sig = "valid" if valid else "INVALID" desc = (f"Publicly verified result: IQ {row['score']}, higher than " f"{row['percentile']}% of test takers. Signature {sig}.") - return HTMLResponse(_inject_meta(html, title, desc, f"/og/{result_id}.png")) + base = oauth.public_base_url() + return HTMLResponse(_inject_meta(html, title, desc, + f"{base}/og/{result_id}.png", + f"{base}/v/{result_id}")) @app.get("/og/{result_id}.png") @@ -202,6 +216,9 @@ @app.post("/api/finish", response_model=FinishResponse) def api_finish(req: FinishRequest, request: Request): + auth = _session_from(request) + if auth is None: + raise HTTPException(401, "Login required") sess = SESSIONS.get(req.session_token) if sess is None: raise HTTPException(404, "Session not found or expired") @@ -217,13 +234,15 @@ flagged = scoring.sanity_check(answers, task_ids, req.total_time_s) score = scoring.score_test(answers, task_ids, req.total_time_s, flagged) - # identity comes from the login cookie (auth-before-test); a new result - # supersedes all previous active results of the same identity - ident = _identity_from(request) + # identity comes from the session cookie (auth-before-test); a new result + # supersedes all previous active results of the same ACCOUNT (all its + # linked identities) — anti-cherry-pick identity = None - if ident: - identity = ident["provider"] + ":@" + ident["username"] - db.supersede_identity(identity) + account_id = None + if auth: + identity = auth["provider"] + ":@" + auth["username"] + account_id = auth["account_id"] + db.supersede_account(account_id) result_id = db.new_result_id() completed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) @@ -249,6 +268,7 @@ answers=answers, flagged=flagged, score_json=json.dumps(score), + account_id=account_id, ) db.log_event("finish", {"flagged": flagged, "iq": score["iq"]}) return FinishResponse(result_id=result_id, score=score) @@ -273,6 +293,7 @@ "percentile": row["percentile"], "completed_at": row["completed_at"], "identity": row["identity"], + "account_id": row.get("account_id"), "bank_version": row["bank_version"], "signature_valid": valid, "signature": row["signature"], @@ -297,13 +318,29 @@ @app.get("/api/me") def api_me(request: Request): - ident = _identity_from(request) - if not ident: - return JSONResponse({"identity": None}) + sess = _session_from(request) + if not sess: + return JSONResponse({"identity": None, "account_id": None, + "identities": [], "active_result": None}) + idents = db.account_identities(sess["account_id"]) + active = db.active_result_for_account(sess["account_id"]) return JSONResponse({ - "identity": ident["provider"] + ":@" + ident["username"], - "username": ident["username"], - "provider": ident["provider"], + "identity": sess["provider"] + ":@" + sess["username"], + "username": sess["username"], + "provider": sess["provider"], + "account_id": sess["account_id"], + "identities": [{"provider": i["provider"], "username": i["username"]} + for i in idents], + "x_configured": oauth.x_configured(), + "ig_configured": oauth.ig_configured(), + "active_result": ({ + "result_id": active["result_id"], + "iq": active["score"], + "percentile": active["percentile"], + "completed_at": active["completed_at"], + "identity": active["identity"], + "flagged": bool(active["flagged"]), + } if active else None), }) @@ -312,6 +349,80 @@ return JSONResponse(db.stats_overview()) +# --- cabinet, logout, account linking ----------------------------------------- + +@app.get("/me") +def me_page(request: Request): + """Personal cabinet: active result + connected accounts.""" + if _session_from(request) is None: + return RedirectResponse("/login", status_code=303) + return FileResponse(FRONTEND_DIR / "me.html") + + +@app.get("/logout") +def logout(): + resp = RedirectResponse("/", status_code=303) + resp.delete_cookie(oauth.SESSION_COOKIE) + return resp + + +@app.get("/link/x/start") +def link_x_start(request: Request): + """Add an X identity to the current account (requires session).""" + sess = _session_from(request) + if sess is None: + return RedirectResponse("/login", status_code=303) + if not oauth.x_configured(): + raise HTTPException(503, "X binding not configured yet") + url = oauth.x_authorize_url(mode="link") + if url is None: + raise HTTPException(503, "X binding not configured yet") + return RedirectResponse(url[0], status_code=303) + + +@app.get("/link/ig/start") +def link_ig_start(request: Request): + """Add an Instagram identity to the current account (requires session).""" + sess = _session_from(request) + if sess is None: + return RedirectResponse("/login", status_code=303) + if not oauth.ig_configured(): + raise HTTPException(503, "Instagram binding not configured yet") + url = oauth.ig_authorize_url(mode="link") + if url is None: + raise HTTPException(503, "Instagram binding not configured yet") + return RedirectResponse(url[0], status_code=303) + + +def _finish_auth(provider: str, user: dict, flow: dict, + request: Request) -> Response: + """Login-or-link: route the OAuth identity to the right account.""" + pid = str(user.get("id", "")) + username = user["username"] + existing = db.get_identity(provider, pid) + if existing: + account_id = existing["account_id"] + if existing["username"] != username: + db.update_identity_username(provider, pid, username) + db.log_event("login", {"provider": provider, "username": username}) + else: + sess = _session_from(request) + if flow.get("mode") == "link" and sess: + account_id = sess["account_id"] + db.create_identity(provider, pid, username, account_id) + db.log_event("link", {"provider": provider, "username": username, + "account_id": account_id}) + else: + account_id = db.create_account() + db.create_identity(provider, pid, username, account_id) + db.log_event("login", {"provider": provider, "username": username}) + token = oauth.sign_session(account_id, provider, username) + resp = RedirectResponse("/me", status_code=303) + resp.set_cookie(oauth.SESSION_COOKIE, token, max_age=oauth.SESSION_TTL_S, + httponly=True, samesite="lax", secure=True) + return resp + + # --- identity binding (phase 2 scaffolding) ----------------------------------- def _login_error_page(provider: str) -> HTMLResponse: """Standalone error page: login failed, no identity cookie is set.""" @@ -343,7 +454,7 @@ @app.get("/auth/x/callback") -def auth_x_callback(code: str = None, state: str = None): +def auth_x_callback(request: Request, code: str = None, state: str = None): if not oauth.x_configured(): raise HTTPException(503, "X binding not configured yet") if not code or not state: @@ -355,12 +466,7 @@ user = oauth.x_userinfo(tok.get("access_token")) if tok else None if not user or not user.get("username"): return _login_error_page("X") - token = oauth.sign_identity("x", str(user.get("id", "")), user["username"]) - db.log_event("login", {"provider": "x", "username": user["username"]}) - resp = RedirectResponse("/test", status_code=303) - resp.set_cookie(oauth.IDENTITY_COOKIE, token, max_age=oauth.IDENTITY_TTL_S, - httponly=True, samesite="lax", secure=True) - return resp + return _finish_auth("x", user, flow, request) @app.get("/auth/ig/start") @@ -375,7 +481,7 @@ @app.get("/auth/ig/callback") -def auth_ig_callback(code: str = None, state: str = None): +def auth_ig_callback(request: Request, code: str = None, state: str = None): if not oauth.ig_configured(): raise HTTPException(503, "Instagram binding not configured yet") if not code or not state: @@ -387,12 +493,7 @@ user = oauth.ig_userinfo(tok.get("access_token")) if tok else None if not user or not user.get("username"): return _login_error_page("Instagram") - token = oauth.sign_identity("ig", str(user.get("id", "")), user["username"]) - db.log_event("login", {"provider": "ig", "username": user["username"]}) - resp = RedirectResponse("/test", status_code=303) - resp.set_cookie(oauth.IDENTITY_COOKIE, token, max_age=oauth.IDENTITY_TTL_S, - httponly=True, samesite="lax", secure=True) - return resp + return _finish_auth("ig", user, flow, request) # --- startup -------------------------------------------------------------------- diff --git a/backend/oauth.py b/backend/oauth.py index 04ab9ee..e0b6840 100644 --- a/backend/oauth.py +++ b/backend/oauth.py @@ -52,7 +52,7 @@ return _env("VERIQ_PUBLIC_BASE_URL") or "http://127.0.0.1:8801" -def _new_flow(provider: str, result_id: str) -> dict: +def _new_flow(provider: str, result_id: str, mode: str = "login") -> dict: verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).decode().rstrip("=") challenge = base64.urlsafe_b64encode( hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=") @@ -60,6 +60,7 @@ FLOWS[state] = { "provider": provider, "result_id": result_id, + "mode": mode, "verifier": verifier, "created_at": time.time(), } @@ -70,12 +71,13 @@ return {"state": state, "challenge": challenge, "verifier": verifier} -def x_authorize_url(result_id: str = "") -> Tuple[str, Optional[str]]: +def x_authorize_url(result_id: str = "", + mode: str = "login") -> Tuple[str, Optional[str]]: """Returns (authorize_url, state) or None if not configured.""" cid = _env("X_CLIENT_ID") if not cid: return None - flow = _new_flow("x", result_id) + flow = _new_flow("x", result_id, mode) params = { "response_type": "code", "client_id": cid, @@ -88,11 +90,12 @@ return X_AUTHORIZE + "?" + urlencode(params), flow["state"] -def ig_authorize_url(result_id: str) -> Tuple[str, Optional[str]]: +def ig_authorize_url(result_id: str = "", + mode: str = "login") -> Tuple[str, Optional[str]]: app_id = _env("IG_APP_ID") if not app_id: return None - flow = _new_flow("ig", result_id) + flow = _new_flow("ig", result_id, mode) params = { "client_id": app_id, "redirect_uri": public_base_url() + "/auth/ig/callback", @@ -226,3 +229,36 @@ if time.time() > expires: return None return {"provider": parts[1], "user_id": parts[2], "username": parts[3]} + +# --- session cookie (account-based, multi-identity) ---------------------------- + +SESSION_COOKIE = "veriq_sid" +SESSION_TTL_S = 7 * 24 * 3600 + + +def sign_session(account_id: str, provider: str, username: str) -> str: + """Stateless Ed25519-signed session token for the veriq_sid cookie.""" + from . import signing + expires = int(time.time()) + SESSION_TTL_S + body = "s1|%s|%s|%s|%d" % (account_id, provider, username, expires) + return body + "|" + signing.sign_payload(body) + + +def verify_session(token: str) -> Optional[dict]: + """Returns {account_id, provider, username} or None (bad signature/expired).""" + from . import signing + if not token: + return None + parts = token.split("|") + if len(parts) != 6 or parts[0] != "s1": + return None + body = "|".join(parts[:5]) + if not signing.verify_payload(body, parts[5]): + return None + try: + expires = int(parts[4]) + except ValueError: + return None + if time.time() > expires: + return None + return {"account_id": parts[1], "provider": parts[2], "username": parts[3]} diff --git a/content/questions.json b/content/questions.json index 7256ae2..2c41bf6 100644 --- a/content/questions.json +++ b/content/questions.json @@ -1,5 +1,5 @@ { - "version": "1.0.0", + "version": "1.0.1", "tasks": [ { "id": "num-001", @@ -319,24 +319,24 @@ "time_limit_s": 45, "prompt": { "text": "How many circles should replace the question mark?", - "svg": "?" + "svg": "?" }, "choices": [ { "text": "4 circles", - "svg": "" + "svg": "" }, { "text": "5 circles", - "svg": "" + "svg": "" }, { "text": "3 circles", - "svg": "" + "svg": "" }, { "text": "2 circles", - "svg": "" + "svg": "" } ], "answer_index": 0, @@ -350,24 +350,24 @@ "time_limit_s": 60, "prompt": { "text": "Which figure continues the sequence?", - "svg": "" + "svg": "" }, "choices": [ { "text": "Hexagon", - "svg": "" + "svg": "" }, { "text": "Heptagon", - "svg": "" + "svg": "" }, { "text": "Circle", - "svg": "" + "svg": "" }, { "text": "Square", - "svg": "" + "svg": "" } ], "answer_index": 0, @@ -381,24 +381,24 @@ "time_limit_s": 60, "prompt": { "text": "Which arrow comes next?", - "svg": "viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>" + "svg": "viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>" }, "choices": [ { "text": "Left", - "svg": "" + "svg": "" }, { "text": "Up", - "svg": "" + "svg": "" }, { "text": "Right", - "svg": "" + "svg": "" }, { "text": "Down", - "svg": "" + "svg": "" } ], "answer_index": 0, @@ -412,24 +412,24 @@ "time_limit_s": 60, "prompt": { "text": "Which circle comes next?", - "svg": "viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>?" + "svg": "viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>?" }, "choices": [ { "text": "Half-filled", - "svg": "" + "svg": "" }, { "text": "Fully filled", - "svg": "" + "svg": "" }, { "text": "Empty", - "svg": "" + "svg": "" }, { "text": "Half-filled, other side", - "svg": "" + "svg": "" } ], "answer_index": 0, @@ -505,24 +505,24 @@ "time_limit_s": 75, "prompt": { "text": "Which arrow comes next? (Fill matters.)", - "svg": "viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>?" + "svg": "viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'>?" }, "choices": [ { "text": "Left, empty", - "svg": "" + "svg": "" }, { "text": "Left, filled", - "svg": "" + "svg": "" }, { "text": "Up, empty", - "svg": "" + "svg": "" }, { "text": "Down, empty", - "svg": "" + "svg": "" } ], "answer_index": 0, @@ -536,24 +536,24 @@ "time_limit_s": 75, "prompt": { "text": "Which figure completes the matrix?", - "svg": "?" + "svg": "?" }, "choices": [ { "text": "Big circle", - "svg": "" + "svg": "" }, { "text": "Small circle", - "svg": "" + "svg": "" }, { "text": "Big square", - "svg": "" + "svg": "" }, { "text": "Small triangle", - "svg": "" + "svg": "" } ], "answer_index": 0, @@ -598,20 +598,20 @@ "time_limit_s": 90, "prompt": { "text": "Panel C must combine panels A and B by XOR: a position gets a dot only if it appears in exactly one of A, B. Which is C?", - "svg": "viewBox='0 0 60 40' xmlns='http://www.w3.org/2000/svg'>AviewBox='0 0 60 40' xmlns='http://www.w3.org/2000/svg'>BviewBox='0 0 60 40' xmlns='http://www.w3.org/2000/svg'>C?" + "svg": "viewBox='0 0 60 40' xmlns='http://www.w3.org/2000/svg'>AviewBox='0 0 60 40' xmlns='http://www.w3.org/2000/svg'>BviewBox='0 0 60 40' xmlns='http://www.w3.org/2000/svg'>C?" }, "choices": [ { "text": "Dots at 1 and 3", - "svg": "" + "svg": "" }, { "text": "Dot at 2 only", - "svg": "" + "svg": "" }, { "text": "Dots at 1, 2 and 3", - "svg": "" + "svg": "" }, { "text": "No dots", @@ -939,7 +939,7 @@ "time_limit_s": 60, "prompt": { "text": "How many triangles are in the figure?", - "svg": "" + "svg": "" }, "choices": [ { @@ -970,24 +970,24 @@ "time_limit_s": 60, "prompt": { "text": "The figure can be rotated in the plane but never flipped (mirrored). Which option is the same figure?", - "svg": "" + "svg": "" }, "choices": [ { "text": "A", - "svg": "" + "svg": "" }, { "text": "B", - "svg": "" + "svg": "" }, { "text": "C", - "svg": "" + "svg": "" }, { "text": "D", - "svg": "" + "svg": "" } ], "answer_index": 0, @@ -1037,19 +1037,19 @@ "choices": [ { "text": "A", - "svg": "" + "svg": "" }, { "text": "B", - "svg": "" + "svg": "" }, { "text": "C", - "svg": "" + "svg": "" }, { "text": "D", - "svg": "" + "svg": "" } ], "answer_index": 0, @@ -1094,24 +1094,24 @@ "time_limit_s": 60, "prompt": { "text": "Which flag is the original rotated 180°? (No flipping allowed.)", - "svg": "" + "svg": "" }, "choices": [ { "text": "A", - "svg": "" + "svg": "" }, { "text": "B", - "svg": "" + "svg": "" }, { "text": "C", - "svg": "" + "svg": "" }, { "text": "D", - "svg": "" + "svg": "" } ], "answer_index": 0, @@ -1156,7 +1156,7 @@ "time_limit_s": 75, "prompt": { "text": "Each stack is one unit cube deep. How many cubes are shown in total?", - "svg": "" + "svg": "" }, "choices": [ { @@ -1187,24 +1187,24 @@ "time_limit_s": 90, "prompt": { "text": "A square sheet is folded in half along the vertical dashed line (left half over right). A hole is punched at the marked position through all layers. Where are the holes after unfolding?", - "svg": "" + "svg": "" }, "choices": [ { "text": "A", - "svg": "" + "svg": "" }, { "text": "B", - "svg": "" + "svg": "" }, { "text": "C", - "svg": "" + "svg": "" }, { "text": "D", - "svg": "" + "svg": "" } ], "answer_index": 0, @@ -1242,4 +1242,4 @@ "explanation": "Only the center cube of each green face has exactly one green face (edge-adjacent cubes touch red or blue too): 2 faces × 1 = 2." } ] -} \ No newline at end of file +} diff --git a/frontend/assets/me.js b/frontend/assets/me.js new file mode 100644 index 0000000..996e2f8 --- /dev/null +++ b/frontend/assets/me.js @@ -0,0 +1,70 @@ +/* VerIQ cabinet: active result + connected accounts */ +(function () { + "use strict"; + + var $ = function (id) { return document.getElementById(id); }; + var esc = function (s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); + }; + + fetch("/api/me").then(function (r) { return r.json(); }).then(function (d) { + if (!d || !d.identity) { window.location.href = "/login"; return; } + + $("whoami").innerHTML = "Logged in as " + + esc(d.identity) + ""; + + // active result + var ab = $("activeBody"); + if (d.active_result) { + var r = d.active_result; + ab.innerHTML = + '
' + + 'IQ ' + esc(r.iq) + "" + + 'higher than ' + esc(r.percentile) + + "% of test takers
" + + '
' + + "completed " + esc(r.completed_at) + + (r.identity ? " · as " + esc(r.identity) : "") + "
" + + '
Open result page → ' + + 'Public verify link →
'; + } else { + ab.innerHTML = '

No result yet — take the test.

'; + } + + // connected identities + var box = $("identities"); + var provs = {}; + (d.identities || []).forEach(function (i) { provs[i.provider] = i.username; }); + var rows = []; + ["x", "ig"].forEach(function (p) { + var label = p === "x" ? "X (Twitter)" : "Instagram"; + if (provs[p]) { + rows.push('
' + + "" + label + " · @" + esc(provs[p]) + "" + + '✓ connected
'); + } else { + rows.push('
' + label + "not connected
"); + } + }); + box.innerHTML = rows.join(""); + + // link buttons: hide already-connected, disable unconfigured providers + if (provs.x) { $("linkX").style.display = "none"; } + if (provs.ig) { $("linkIg").style.display = "none"; } + if (d.ig_configured === false) { + $("linkIg").style.display = "none"; + } + if (d.x_configured === false) { + $("linkX").style.display = "none"; + } + }).catch(function () { + window.location.href = "/login"; + }); +})(); diff --git a/frontend/me.html b/frontend/me.html new file mode 100644 index 0000000..205a8ae --- /dev/null +++ b/frontend/me.html @@ -0,0 +1,55 @@ + + + + + +VerIQ — my cabinet + + + + +
+
+ + my cabinet +
+ +
+

My cabinet

+

Loading…

+
+ +
+

Current active result

+
Loading…
+
+ +
+ Start the test → +

+ A new attempt replaces your current active result — one active result + per account, no cherry-picking. +

+
+ +
+

Connected accounts

+
Loading…
+
+ Connect X → + Connect Instagram → +
+
+ +
+ Log out +
+ + +
+ + +