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": ""
+ "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": ""
+ "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": ""
+ "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": ""
+ "svg": ""
},
"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) : "") + "
" +
+ '';
+ } 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
+ 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…
+
+
+
+
+
+
+
+
+
+