From 519daa1d8fb7286f4b29ec42cea3d5010f41390d Mon Sep 17 00:00:00 2001 From: Krish Date: Sun, 23 Aug 2026 09:54:42 +0530 Subject: [PATCH 1/2] feat: add the anonymous-first profile funnel A candidate is a pseudonym until their work earns the introduction. This adds the reveal mechanism and the profile surface that reads it. - users gains `revealed` (default False) and `name`, documented in models/user.py. user_repository backfills both on read, so accounts written before the field existed behave as unrevealed rather than as missing-key. - services/reputation_service.py owns the funnel. `meets_reveal_threshold` is a placeholder for the real reputation score: one graded attempt at 70+. Only `status: graded` counts, so an undefended score cannot buy a reveal. - GET /profile/{user_id} returns "Anonymous Candidate" with a null email until the threshold is cleared, then latches the account open and returns the real details. Identity is dropped in the service, not hidden in the UI, so an unrevealed name never reaches the browser. - Frontend gains a Profile tab; display.js re-checks `revealed` as a second lock. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013o2ByD8XzuAAuwyjhjzYn4 --- backend/app/api/v1/endpoints/profile.py | 32 +++ backend/app/api/v1/router.py | 3 +- backend/app/models/user.py | 16 +- backend/app/repositories/quiz_repository.py | 18 ++ backend/app/repositories/user_repository.py | 30 ++- backend/app/schemas/profile.py | 19 ++ backend/app/services/auth_service.py | 3 + backend/app/services/reputation_service.py | 80 +++++++ backend/tests/test_auth_api.py | 6 + backend/tests/test_profile_api.py | 211 ++++++++++++++++++ docs/ARCHITECTURE.md | 34 ++- frontend/src/App.jsx | 24 +- frontend/src/features/profile/ProfilePage.jsx | 70 ++++++ frontend/src/features/profile/api.js | 8 + frontend/src/features/profile/display.js | 14 ++ frontend/src/features/profile/display.test.js | 26 +++ frontend/src/shared/api/token.js | 16 ++ frontend/src/styles/globals.css | 16 +- 18 files changed, 611 insertions(+), 15 deletions(-) create mode 100644 backend/app/api/v1/endpoints/profile.py create mode 100644 backend/app/schemas/profile.py create mode 100644 backend/app/services/reputation_service.py create mode 100644 backend/tests/test_profile_api.py create mode 100644 frontend/src/features/profile/ProfilePage.jsx create mode 100644 frontend/src/features/profile/api.js create mode 100644 frontend/src/features/profile/display.js create mode 100644 frontend/src/features/profile/display.test.js diff --git a/backend/app/api/v1/endpoints/profile.py b/backend/app/api/v1/endpoints/profile.py new file mode 100644 index 0000000..b2aaca0 --- /dev/null +++ b/backend/app/api/v1/endpoints/profile.py @@ -0,0 +1,32 @@ +""" +Thin HTTP layer for candidate profiles. No business logic here — only +request/response translation and HTTP error mapping. Logic lives in +app/services/reputation_service.py. + +Deliberately open: the funnel exists so an employer can browse candidates before +either side has committed to anything, and requiring a token to read a profile +that is anonymous by construction would gate the wrong thing. Anonymity, not +authentication, is what protects the candidate here. +""" +from fastapi import APIRouter, HTTPException + +from app.schemas.profile import ProfileResponse +from app.services import reputation_service + +router = APIRouter() + + +@router.get("/{user_id}", response_model=ProfileResponse) +async def get_profile(user_id: str): + """ + Returns the profile, revealing the candidate if they have cleared the threshold. + + A GET that can flip `revealed` is a write on a read path, which is not usually + right. It is here because the reveal belongs to the candidate's own record — + it depends on what they scored, never on who is looking — so any viewer + triggering it produces the same result, and it is idempotent after the first. + """ + try: + return await reputation_service.get_public_profile(user_id) + except LookupError: + raise HTTPException(404, "Profile not found") diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index ffa5d62..16b9ecf 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,9 +1,10 @@ """Aggregates every v1 route. main.py only ever imports this one router.""" from fastapi import APIRouter -from app.api.v1.endpoints import auth, jobs, quiz +from app.api.v1.endpoints import auth, jobs, profile, quiz api_router = APIRouter() api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(quiz.router, prefix="/quiz", tags=["quiz"]) api_router.include_router(jobs.router, prefix="/jobs", tags=["jobs"]) +api_router.include_router(profile.router, prefix="/profile", tags=["profile"]) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 396d438..4fd347e 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -9,15 +9,23 @@ "email": str, # stored lowercased; unique, see note below "hashed_password": str, # bcrypt, never the plaintext "role": "candidate" | "employer", + "name": str | None, # real display name; not collected at registration yet, + # so it is absent on every account created so far + "revealed": bool, # default False — see the anonymity note below "created_at": datetime (UTC), } The password is never stored or logged in any other form, and no query in user_repository.py returns it except the one login needs. -Uniqueness note: email uniqueness is currently enforced by a read-before-write in -auth_service.register_user, which is racy under concurrent signups. A unique index -on `email` is the real fix: +Anonymity note: `revealed` drives the anonymous-first funnel. While it is False, +GET /profile/{user_id} answers with "Anonymous Candidate" and no email — the +identifying fields are dropped in services/reputation_service.py, not merely +hidden in the UI, so a profile response never carries a name or an address the +viewer is not entitled to. - db.users.create_index("email", unique=True) +It is a one-way latch: reputation_service flips it to True the first time the +candidate clears the reveal threshold, and nothing sets it back. Accounts created +before this field existed have no `revealed` key at all; every read treats a +missing value as False, so the safe state is also the default. """ diff --git a/backend/app/repositories/quiz_repository.py b/backend/app/repositories/quiz_repository.py index 4d14d95..608b3e9 100644 --- a/backend/app/repositories/quiz_repository.py +++ b/backend/app/repositories/quiz_repository.py @@ -30,3 +30,21 @@ async def update_result(quiz_id: str, result: dict, followup: dict | None = None if followup is not None: changes["followup"] = followup await collection.update_one({"_id": quiz_id}, {"$set": changes}) + + +async def has_graded_attempt_scoring_at_least(user_id: str, minimum: float) -> bool: + """ + True if this user owns at least one graded attempt at or above `minimum`. + + Only `status: "graded"` counts: an attempt still awaiting its follow-up has no + defended score, and reading `result` off one would credit a score the candidate + has not yet had to stand behind. + + Projected down to `_id` because the caller only needs the yes/no — there is no + reason to pull whole attempt documents across the wire to answer it. + """ + match = await collection.find_one( + {"user_id": user_id, "status": "graded", "result.overall_score": {"$gte": minimum}}, + {"_id": 1}, + ) + return match is not None diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py index a57ff58..4c90221 100644 --- a/backend/app/repositories/user_repository.py +++ b/backend/app/repositories/user_repository.py @@ -9,19 +9,43 @@ collection = get_collection("users") +# Fields every user document is expected to carry, and what a document written +# before the field existed should be read as. Applied on write (so new documents +# are complete) and on read (so old ones behave as if they were). +DEFAULTS = { + "name": None, + "revealed": False, +} + def _normalise_email(email: str) -> str: """Emails are matched case-insensitively, so they are stored folded.""" return email.strip().lower() +def _with_defaults(doc: Optional[dict]) -> Optional[dict]: + """Backfills absent fields in memory so callers never branch on a missing key.""" + if doc is None: + return None + return {**DEFAULTS, **doc} + + async def create_user(doc: dict) -> None: - await collection.insert_one(doc) + await collection.insert_one({**DEFAULTS, **doc}) async def get_user_by_email(email: str) -> Optional[dict]: - return await collection.find_one({"email": _normalise_email(email)}) + return _with_defaults(await collection.find_one({"email": _normalise_email(email)})) async def get_user_by_id(user_id: str) -> Optional[dict]: - return await collection.find_one({"_id": user_id}) + return _with_defaults(await collection.find_one({"_id": user_id})) + + +async def mark_revealed(user_id: str) -> None: + """ + Latch the account as revealed. One way on purpose: a candidate who has cleared + the threshold and been seen by employers cannot be put back behind the + pseudonym, so there is no un-reveal counterpart to this. + """ + await collection.update_one({"_id": user_id}, {"$set": {"revealed": True}}) diff --git a/backend/app/schemas/profile.py b/backend/app/schemas/profile.py new file mode 100644 index 0000000..39fe2d4 --- /dev/null +++ b/backend/app/schemas/profile.py @@ -0,0 +1,19 @@ +"""Request/response DTOs for the profile endpoint — what crosses the wire.""" +from pydantic import BaseModel + +from app.schemas.auth import Role + + +class ProfileResponse(BaseModel): + """ + A profile as a viewer may see it. + + `name` carries the pseudonym while `revealed` is False, and `email` is None — + the fields are absent from the payload rather than blanked by the client, so + an unrevealed identity is never sent to the browser at all. + """ + user_id: str + name: str | None + email: str | None + role: Role + revealed: bool diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index ce7b870..b33f19f 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -25,6 +25,9 @@ async def register_user(email: str, password: str, role: str) -> dict: "email": normalised, "hashed_password": hash_password(password), "role": role, + # Every account starts behind the pseudonym; reputation_service is the + # only thing that flips this, once the reveal threshold is cleared. + "revealed": False, "created_at": datetime.now(timezone.utc), } ) diff --git a/backend/app/services/reputation_service.py b/backend/app/services/reputation_service.py new file mode 100644 index 0000000..41919a9 --- /dev/null +++ b/backend/app/services/reputation_service.py @@ -0,0 +1,80 @@ +""" +Business logic for the anonymous-first funnel. + +A candidate is a pseudonym until their work says otherwise. Employers browse +"Anonymous Candidate" profiles and only learn who someone is once that candidate +has demonstrated something — which is the whole point of the platform: the code +earns the introduction, not the CV. + +Two things live here: + + * `meets_reveal_threshold` — has this candidate earned it yet? Today that is a + single graded quiz at or above REVEAL_MIN_SCORE. The real reputation score + (quiz depth + interview rounds, per docs/ARCHITECTURE.md) replaces the body of + this one function; nothing else below has to change when it lands. + * `get_public_profile` — assembles what a viewer is allowed to see. + +Identity is dropped here, in the service, rather than hidden in the UI. An +unrevealed profile has no name and no email anywhere in the response, so there is +nothing for a curious viewer to read out of the network tab. +""" +from app.repositories import quiz_repository, user_repository + +ANONYMOUS_NAME = "Anonymous Candidate" + +# One defended quiz at 70+ is the current bar. Deliberately a module constant: +# the tests assert against this rather than a literal, so moving the bar cannot +# quietly leave the suite asserting the old one. +REVEAL_MIN_SCORE = 70.0 + + +async def meets_reveal_threshold(user_id: str) -> bool: + """ + Placeholder for the real reputation score. + + For now: does this candidate hold at least one graded attempt scoring + REVEAL_MIN_SCORE or better? Grading only happens after the follow-up defence, + so a passing score here already means the answers survived being questioned. + """ + return await quiz_repository.has_graded_attempt_scoring_at_least(user_id, REVEAL_MIN_SCORE) + + +async def get_public_profile(user_id: str) -> dict: + """ + The profile as a viewer may see it, revealing the candidate if they have earned it. + + Reveal is evaluated on read rather than written at grading time so that a + change to the threshold applies to everyone immediately, without a migration + over past attempts. The flag is still persisted once it flips: it is a latch, + so a candidate who was revealed stays revealed even if the bar later rises. + + Raises LookupError for an unknown user; the endpoint turns that into a 404. + """ + user = await user_repository.get_user_by_id(user_id) + if not user: + raise LookupError("user_not_found") + + revealed = bool(user.get("revealed")) + if not revealed and await meets_reveal_threshold(user_id): + await user_repository.mark_revealed(user_id) + revealed = True + + if not revealed: + return { + "user_id": user["_id"], + "name": ANONYMOUS_NAME, + "email": None, + "role": user.get("role", "candidate"), + "revealed": False, + } + + return { + "user_id": user["_id"], + # `name` is not collected at registration yet, so it is None on every + # account so far. The profile view falls back to the email in that case; + # both are the candidate's to show once revealed. + "name": user.get("name"), + "email": user.get("email"), + "role": user.get("role", "candidate"), + "revealed": True, + } diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py index 90e4617..e96e7ab 100644 --- a/backend/tests/test_auth_api.py +++ b/backend/tests/test_auth_api.py @@ -59,6 +59,12 @@ def test_register_rejects_a_duplicate_email(existing_user): assert resp.status_code == 409 +def test_register_starts_the_account_anonymous(no_such_user): + """The funnel's safe state: a new account is a pseudonym until it earns otherwise.""" + client.post("/api/v1/auth/register", json={"email": "c@d.com", "password": "hunter2hunter2"}) + assert no_such_user.call_args.args[0]["revealed"] is False + + def test_register_defaults_to_candidate(no_such_user): client.post("/api/v1/auth/register", json={"email": "c@d.com", "password": "hunter2hunter2"}) assert no_such_user.call_args.args[0]["role"] == "candidate" diff --git a/backend/tests/test_profile_api.py b/backend/tests/test_profile_api.py new file mode 100644 index 0000000..42a8174 --- /dev/null +++ b/backend/tests/test_profile_api.py @@ -0,0 +1,211 @@ +""" +The anonymous-first funnel. + +The promise the product makes is that a candidate is a pseudonym until their work +earns the introduction. That promise is only worth something if the identifying +fields are absent from the response — not merely unrendered — so these tests +assert on the payload, and on the fact that the reveal is written down once it +happens. +""" +from unittest.mock import AsyncMock + +import pytest +from fastapi.testclient import TestClient + +from app.main import app +from app.repositories import user_repository +from app.services import reputation_service +from app.services.reputation_service import ANONYMOUS_NAME, REVEAL_MIN_SCORE + +client = TestClient(app) + +USER = { + "_id": "user-1", + "email": "ada@example.com", + "name": "Ada Lovelace", + "hashed_password": "irrelevant", + "role": "candidate", + "revealed": False, +} + + +@pytest.fixture +def store(monkeypatch): + """ + Stands in for Mongo: one user, and a switch for whether they hold a passing + attempt. `revealed` writes land back on the same dict, so a test can assert + the latch was persisted rather than only reported. + """ + user = dict(USER) + + async def get_user_by_id(user_id): + return dict(user) if user_id == user["_id"] else None + + async def mark_revealed(user_id): + assert user_id == user["_id"] + user["revealed"] = True + + monkeypatch.setattr(reputation_service.user_repository, "get_user_by_id", get_user_by_id) + monkeypatch.setattr(reputation_service.user_repository, "mark_revealed", mark_revealed) + monkeypatch.setattr( + reputation_service.quiz_repository, + "has_graded_attempt_scoring_at_least", + AsyncMock(return_value=False), + ) + return user + + +@pytest.fixture +def passing(monkeypatch): + """Make the threshold query answer yes, as it would for a 70+ graded attempt.""" + query = AsyncMock(return_value=True) + monkeypatch.setattr( + reputation_service.quiz_repository, "has_graded_attempt_scoring_at_least", query + ) + return query + + +# --- anonymous by default -------------------------------------------------- + +def test_a_fresh_users_profile_is_anonymous(store): + resp = client.get("/api/v1/profile/user-1") + + assert resp.status_code == 200 + body = resp.json() + assert body["name"] == ANONYMOUS_NAME + assert body["email"] is None + assert body["revealed"] is False + + +def test_an_anonymous_profile_carries_no_identifying_string_at_all(store): + """ + The point of stripping in the service: if the real name or address appeared + anywhere in the payload, the pseudonym would be a UI convention rather than a + guarantee, and the network tab would undo it. + """ + raw = client.get("/api/v1/profile/user-1").text + + assert "Ada Lovelace" not in raw + assert "ada@example.com" not in raw + + +def test_an_anonymous_profile_still_shows_the_role(store): + """The funnel hides who someone is, not what they are here to do.""" + assert client.get("/api/v1/profile/user-1").json()["role"] == "candidate" + + +def test_an_unknown_user_is_404(store): + assert client.get("/api/v1/profile/nobody").status_code == 404 + + +# --- reveal ---------------------------------------------------------------- + +def test_a_passing_score_reveals_the_candidate(store, passing): + resp = client.get("/api/v1/profile/user-1") + + assert resp.status_code == 200 + body = resp.json() + assert body["revealed"] is True + assert body["name"] == "Ada Lovelace" + assert body["email"] == "ada@example.com" + + +def test_the_threshold_is_asked_for_the_documented_score(store, passing): + client.get("/api/v1/profile/user-1") + assert passing.call_args.args == ("user-1", REVEAL_MIN_SCORE) + + +def test_the_reveal_is_persisted_not_just_returned(store, passing): + """Otherwise every viewer would re-run the threshold query for a settled fact.""" + client.get("/api/v1/profile/user-1") + assert store["revealed"] is True + + +def test_an_already_revealed_user_is_not_re_evaluated(store): + """ + A latch: once revealed, the threshold is no longer consulted, so raising the + bar later cannot retract an identity employers have already seen. + """ + store["revealed"] = True + resp = client.get("/api/v1/profile/user-1") + + assert resp.json()["name"] == "Ada Lovelace" + reputation_service.quiz_repository.has_graded_attempt_scoring_at_least.assert_not_called() + + +def test_a_user_below_the_threshold_stays_anonymous(store): + resp = client.get("/api/v1/profile/user-1") + + assert resp.json()["revealed"] is False + assert store["revealed"] is False, "a failed check must not latch the account open" + + +# --- the threshold itself -------------------------------------------------- + +async def test_the_threshold_asks_only_for_defended_scores(monkeypatch): + """ + Grading happens after the follow-up, so `status: graded` is what separates a + score the candidate defended from one they merely started. + """ + captured = {} + + async def fake_find_one(query, projection=None): + captured["query"] = query + return None + + monkeypatch.setattr(reputation_service.quiz_repository, "collection", type( + "C", (), {"find_one": staticmethod(fake_find_one)} + )) + + assert await reputation_service.meets_reveal_threshold("user-1") is False + assert captured["query"]["user_id"] == "user-1" + assert captured["query"]["status"] == "graded" + assert captured["query"]["result.overall_score"] == {"$gte": REVEAL_MIN_SCORE} + + +# --- the users collection -------------------------------------------------- + +class FakeCollection: + """Records what the repository asked Mongo to do.""" + + def __init__(self, doc=None): + self.doc = doc + self.updates = [] + + async def find_one(self, query, projection=None): + return dict(self.doc) if self.doc else None + + async def update_one(self, query, changes): + self.updates.append((query, changes)) + + async def insert_one(self, doc): + self.doc = doc + + +async def test_an_account_predating_the_field_reads_as_anonymous(monkeypatch): + """ + Documents are not migrated, so the absent key must behave as the safe value. + If this ever read as truthy, every legacy account would be revealed at once. + """ + legacy = {"_id": "old-user", "email": "old@example.com", "role": "candidate"} + monkeypatch.setattr(user_repository, "collection", FakeCollection(legacy)) + + user = await user_repository.get_user_by_id("old-user") + assert user["revealed"] is False + assert user["name"] is None + + +async def test_a_new_account_is_written_with_the_field(monkeypatch): + fake = FakeCollection() + monkeypatch.setattr(user_repository, "collection", fake) + + await user_repository.create_user({"_id": "u", "email": "a@b.com", "role": "candidate"}) + assert fake.doc["revealed"] is False + + +async def test_mark_revealed_sets_only_that_field(monkeypatch): + fake = FakeCollection({"_id": "u"}) + monkeypatch.setattr(user_repository, "collection", fake) + + await user_repository.mark_revealed("u") + assert fake.updates == [({"_id": "u"}, {"$set": {"revealed": True}})] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dcf177f..9513fff 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -49,6 +49,26 @@ POST /api/v1/quiz/generate ← 200 JSON ``` +## Request flow example - viewing a profile + +``` +GET /api/v1/profile/{user_id} + -> api/v1/endpoints/profile.py::get_profile() + -> services/reputation_service.py::get_public_profile() + -> repositories/user_repository.py::get_user_by_id() (Mongo) + -> repositories/quiz_repository.py (Mongo) + ::has_graded_attempt_scoring_at_least() + -> repositories/user_repository.py::mark_revealed() (Mongo, only on + the first reveal) + <- ProfileResponse + <- 200 JSON +``` + +Identity is dropped in the service, not in the UI: while `revealed` is False the +response carries `"Anonymous Candidate"` and a null email, so an unrevealed name +never reaches the browser at all. `features/profile/display.js` re-checks the flag +as a second lock. + ## Frontend structure ``` @@ -67,10 +87,14 @@ should ever import from a `features/` folder — dependencies point inward. ## What's next architecturally (not yet built) -- `auth` module (backend) + `features/auth` (frontend) — needed before the - anonymous-first funnel can gate anything for real. -- `services/scoring_service.py` — the unified reputation score - (quiz depth + interview rounds elsewhere). Reads from quiz + application - outcomes, writes to a `scores` collection via a new `score_repository.py`. +- The real reputation score. `services/reputation_service.py` exists and owns the + anonymous-first funnel, but `meets_reveal_threshold()` is a placeholder: one + graded quiz at `REVEAL_MIN_SCORE` or better. The unified score (quiz depth + + interview rounds elsewhere) replaces the body of that one function — it reads + from quiz + application outcomes and writes to a `scores` collection via a new + `score_repository.py`. Nothing else in the funnel changes when it lands. +- A real display name. `users.name` is documented and rendered but nothing + collects it yet, so a revealed profile currently falls back to showing the + email. Registration needs a name field before the reveal reads well. - `services/company_quiz_service.py` — same quiz engine, different prompt, gates job posting creation. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 61f029d..df66544 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,6 @@ import { useState } from "react"; import QuizPage from "./features/quiz/QuizPage"; +import ProfilePage from "./features/profile/ProfilePage"; import LoginPage from "./features/auth/LoginPage"; import RegisterPage from "./features/auth/RegisterPage"; import { isLoggedIn, logout } from "./features/auth/api"; @@ -9,6 +10,7 @@ export default function App() { // Seeded from storage so a reload does not log you out. const [authed, setAuthed] = useState(isLoggedIn); const [showRegister, setShowRegister] = useState(false); + const [tab, setTab] = useState("quiz"); if (!authed) { const Page = showRegister ? RegisterPage : LoginPage; @@ -21,9 +23,23 @@ export default function App() { ); } + const onUnauthorized = () => setAuthed(false); + return ( <>
+ +
- setAuthed(false)} /> + {tab === "quiz" ? ( + + ) : ( + // Re-fetched on every visit, so a reveal earned in the quiz tab shows up + // as soon as the candidate looks. + + )} ); } diff --git a/frontend/src/features/profile/ProfilePage.jsx b/frontend/src/features/profile/ProfilePage.jsx new file mode 100644 index 0000000..c6ca76d --- /dev/null +++ b/frontend/src/features/profile/ProfilePage.jsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from "react"; +import { getProfile } from "./api"; +import { displayName } from "./display"; +import { getUserId } from "../../shared/api/token"; + +// Anonymous-first: a candidate is a pseudonym here until a defended quiz score +// clears the reveal threshold, at which point the backend latches them open and +// this page starts showing who they are. The reveal is evaluated server-side on +// every read, so simply revisiting this page after passing is enough. +export default function ProfilePage({ userId, onUnauthorized }) { + const subject = userId ?? getUserId(); + const [profile, setProfile] = useState(null); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let live = true; + if (!subject) { + setError("No profile to show."); + setLoading(false); + return; + } + setLoading(true); + getProfile(subject) + .then((data) => live && setProfile(data)) + .catch((e) => { + if (!live) return; + if (e.status === 401) return onUnauthorized?.(); + setError(e.message); + }) + .finally(() => live && setLoading(false)); + // Ignore a response that arrives after the subject changed or the page left. + return () => { + live = false; + }; + }, [subject]); + + if (loading) return
Loading profile...
; + if (error) return

{error}

; + + const revealed = Boolean(profile?.revealed); + + return ( +
+

{displayName(profile)}

+

{profile?.role === "employer" ? "Employer" : "Candidate"}

+ +
+ {revealed ? ( + <> +

Revealed

+ {profile.email &&

{profile.email}

} +

+ A defended quiz score cleared the threshold, so employers browsing + this profile now see who you are. +

+ + ) : ( + <> +

Anonymous

+

+ Your name and email are not sent to anyone viewing this page. Score + 70 or better on a repo quiz and your identity is revealed here. +

+ + )} +
+
+ ); +} diff --git a/frontend/src/features/profile/api.js b/frontend/src/features/profile/api.js new file mode 100644 index 0000000..f91d67b --- /dev/null +++ b/frontend/src/features/profile/api.js @@ -0,0 +1,8 @@ +import request from "../../shared/api/client"; + +// Open on the backend: an employer can read a profile before either side has +// committed to anything, which is the point of the funnel. The token is still +// attached by the client when one exists. +export function getProfile(userId) { + return request(`/profile/${encodeURIComponent(userId)}`); +} diff --git a/frontend/src/features/profile/display.js b/frontend/src/features/profile/display.js new file mode 100644 index 0000000..50c2fad --- /dev/null +++ b/frontend/src/features/profile/display.js @@ -0,0 +1,14 @@ +// What a viewer is shown for a candidate's identity. +// +// The backend already strips the name and email from an unrevealed profile, so +// this is the second of two locks rather than the only one: even if a payload +// arrived carrying identity it should not, `revealed` alone decides what renders. +// Kept as a plain function so it can be tested without a DOM. +export const ANONYMOUS_NAME = "Anonymous Candidate"; + +export function displayName(profile) { + if (!profile?.revealed) return ANONYMOUS_NAME; + // `name` is not collected at registration yet, so most revealed profiles fall + // through to the email — which is the candidate's to show once revealed. + return profile.name || profile.email || ANONYMOUS_NAME; +} diff --git a/frontend/src/features/profile/display.test.js b/frontend/src/features/profile/display.test.js new file mode 100644 index 0000000..0025dbd --- /dev/null +++ b/frontend/src/features/profile/display.test.js @@ -0,0 +1,26 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { displayName, ANONYMOUS_NAME } from "./display.js"; + +test("an unrevealed profile shows the pseudonym", () => { + assert.equal(displayName({ revealed: false, name: null, email: null }), ANONYMOUS_NAME); +}); + +test("a revealed profile shows the real name", () => { + assert.equal(displayName({ revealed: true, name: "Ada Lovelace", email: "ada@x.com" }), "Ada Lovelace"); +}); + +test("a revealed profile with no name falls back to the email", () => { + assert.equal(displayName({ revealed: true, name: null, email: "ada@x.com" }), "ada@x.com"); +}); + +test("identity in an unrevealed payload is still not rendered", () => { + // The server should never send this. If it ever did, the UI must not be the + // thing that leaks it. + assert.equal(displayName({ revealed: false, name: "Ada Lovelace", email: "ada@x.com" }), ANONYMOUS_NAME); +}); + +test("a missing profile shows the pseudonym rather than throwing", () => { + assert.equal(displayName(undefined), ANONYMOUS_NAME); + assert.equal(displayName(null), ANONYMOUS_NAME); +}); diff --git a/frontend/src/shared/api/token.js b/frontend/src/shared/api/token.js index 362d10a..13b2a1c 100644 --- a/frontend/src/shared/api/token.js +++ b/frontend/src/shared/api/token.js @@ -31,3 +31,19 @@ export function clearToken() { /* nothing to do */ } } + +export function getUserId() { + // The `sub` claim, read straight off the token payload. Reading it here saves a + // round trip for "whose profile am I looking at by default" — it is not a + // security decision, and nothing here verifies the signature. Every claim that + // matters is re-checked by the backend against the signed token. + const token = getToken(); + if (!token) return null; + try { + const payload = token.split(".")[1]; + const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/")); + return JSON.parse(json).sub ?? null; + } catch { + return null; // malformed token — treat it as no session rather than crashing + } +} diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 803bede..77bb20e 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -62,7 +62,10 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .question.locked { opacity: 0.65; } .question textarea:disabled { cursor: not-allowed; } -.topbar { max-width: 720px; margin: 0 auto; padding: 16px 20px 0; text-align: right; } +.topbar { + max-width: 720px; margin: 0 auto; padding: 16px 20px 0; + display: flex; justify-content: flex-end; gap: 18px; +} .linkish { background: none; border: none; color: #5b8cff; padding: 0; font-weight: 600; cursor: pointer; text-decoration: underline; @@ -75,3 +78,14 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } background: #171922; color: #e8e8ea; font-size: 14px; } .auth .switch { margin: 0; font-size: 14px; color: #9a9aa5; } + +.linkish.current { color: #e8e8ea; text-decoration: none; } + +.profile-card { margin-top: 24px; padding: 16px; background: #171922; border-radius: 10px; } +.profile-card .s { margin: 0 0 8px 0; color: #9a9aa5; font-size: 14px; } +.profile-card .s:last-child { margin-bottom: 0; } +.reveal-state { + margin: 0 0 10px 0; font-size: 11px; text-transform: uppercase; + letter-spacing: 0.06em; color: #7d8194; +} +.reveal-state.revealed { color: #5bd68c; } From c1ca3751386f9b5a04986418a9c122f247e1d50c Mon Sep 17 00:00:00 2001 From: Krish Date: Sun, 23 Aug 2026 10:06:20 +0530 Subject: [PATCH 2/2] feat: collect a name at registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reveal had nothing to reveal: `users.name` was documented and rendered but nothing wrote it, so a revealed profile fell back to showing an email address — not the introduction the funnel promises. `name` is required on POST /auth/register, stripped before it is measured so a name of spaces is refused rather than stored as one. The profile view keeps its email fallback for accounts created before the field existed. AuthForm's `showRole` flag is now `signup`: it already meant "this is the register form" in three places, and the name field would have made it four. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013o2ByD8XzuAAuwyjhjzYn4 --- backend/app/api/v1/endpoints/auth.py | 2 +- backend/app/models/user.py | 5 +-- backend/app/schemas/auth.py | 14 ++++++-- backend/app/services/auth_service.py | 5 ++- backend/app/services/reputation_service.py | 6 ++-- backend/tests/test_auth_api.py | 36 +++++++++++++++------ docs/ARCHITECTURE.md | 3 -- frontend/src/features/auth/AuthForm.jsx | 36 +++++++++++++++++---- frontend/src/features/auth/RegisterPage.jsx | 6 ++-- frontend/src/features/auth/api.js | 4 +-- frontend/src/features/profile/display.js | 4 +-- frontend/src/styles/globals.css | 1 + 12 files changed, 88 insertions(+), 34 deletions(-) diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index dba56e6..dac6434 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -14,7 +14,7 @@ @router.post("/register", response_model=TokenResponse, status_code=201) async def register(req: RegisterRequest): try: - return await auth_service.register_user(req.email, req.password, req.role) + return await auth_service.register_user(req.email, req.password, req.role, req.name) except ValueError: # 409 rather than 400: the request was well-formed, the address is taken. raise HTTPException(409, "That email is already registered.") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 4fd347e..981c6cd 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -9,8 +9,9 @@ "email": str, # stored lowercased; unique, see note below "hashed_password": str, # bcrypt, never the plaintext "role": "candidate" | "employer", - "name": str | None, # real display name; not collected at registration yet, - # so it is absent on every account created so far + "name": str | None, # real display name, collected at registration and + # stripped of surrounding whitespace. None only on + # accounts created before the field existed "revealed": bool, # default False — see the anonymity note below "created_at": datetime (UTC), } diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index e0df7fd..b000ca5 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -1,12 +1,22 @@ """Request/response DTOs for the auth endpoints — what crosses the wire.""" -from typing import Literal +from typing import Annotated, Literal -from pydantic import BaseModel, EmailStr, Field +from pydantic import BaseModel, EmailStr, Field, StringConstraints Role = Literal["candidate", "employer"] +# Stripped before it is measured, so a name of spaces is rejected rather than +# stored as one. The cap is a storage guard, not a claim about how long real +# names are — it is deliberately generous. +Name = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=80)] + + class RegisterRequest(BaseModel): + # Required: this is the name an employer sees once the candidate clears the + # reveal threshold, and a funnel that reveals an email address instead is not + # the introduction the product promises. + name: Name email: EmailStr # bcrypt only considers the first 72 bytes, so longer input is rejected rather # than silently truncated at the boundary. diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index b33f19f..976cdb2 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -11,7 +11,7 @@ from app.repositories import user_repository -async def register_user(email: str, password: str, role: str) -> dict: +async def register_user(email: str, password: str, role: str, name: str) -> dict: """Creates the account and returns a token, so signup does not need a second round trip.""" normalised = email.strip().lower() @@ -23,6 +23,9 @@ async def register_user(email: str, password: str, role: str) -> dict: { "_id": user_id, "email": normalised, + # Shown only once `revealed` flips; until then it never leaves this file's + # collection. See services/reputation_service.py. + "name": name.strip(), "hashed_password": hash_password(password), "role": role, # Every account starts behind the pseudonym; reputation_service is the diff --git a/backend/app/services/reputation_service.py b/backend/app/services/reputation_service.py index 41919a9..ee089fa 100644 --- a/backend/app/services/reputation_service.py +++ b/backend/app/services/reputation_service.py @@ -70,9 +70,9 @@ async def get_public_profile(user_id: str) -> dict: return { "user_id": user["_id"], - # `name` is not collected at registration yet, so it is None on every - # account so far. The profile view falls back to the email in that case; - # both are the candidate's to show once revealed. + # None only on accounts created before registration collected a name; + # the profile view falls back to the email for those. Both are the + # candidate's to show once revealed. "name": user.get("name"), "email": user.get("email"), "role": user.get("role", "candidate"), diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py index e96e7ab..b349e24 100644 --- a/backend/tests/test_auth_api.py +++ b/backend/tests/test_auth_api.py @@ -40,7 +40,8 @@ def auth_header(user_id="user-1", role="candidate"): def test_register_creates_a_user_and_returns_a_token(no_such_user): resp = client.post("/api/v1/auth/register", - json={"email": "New@Example.com", "password": "hunter2hunter2", "role": "employer"}) + json={"name": "Ada Lovelace", "email": "New@Example.com", + "password": "hunter2hunter2", "role": "employer"}) assert resp.status_code == 201 body = resp.json() assert body["token_type"] == "bearer" and body["role"] == "employer" @@ -48,6 +49,7 @@ def test_register_creates_a_user_and_returns_a_token(no_such_user): doc = no_such_user.call_args.args[0] assert doc["email"] == "new@example.com", "email must be stored folded" + assert doc["name"] == "Ada Lovelace", "the name is stored as given, not folded" assert doc["role"] == "employer" assert "password" not in doc assert doc["hashed_password"] != "hunter2hunter2" @@ -55,27 +57,43 @@ def test_register_creates_a_user_and_returns_a_token(no_such_user): def test_register_rejects_a_duplicate_email(existing_user): resp = client.post("/api/v1/auth/register", - json={"email": "a@b.com", "password": "hunter2hunter2"}) + json={"name": "Ada", "email": "a@b.com", "password": "hunter2hunter2"}) assert resp.status_code == 409 def test_register_starts_the_account_anonymous(no_such_user): """The funnel's safe state: a new account is a pseudonym until it earns otherwise.""" - client.post("/api/v1/auth/register", json={"email": "c@d.com", "password": "hunter2hunter2"}) + client.post("/api/v1/auth/register", + json={"name": "Ada", "email": "c@d.com", "password": "hunter2hunter2"}) assert no_such_user.call_args.args[0]["revealed"] is False def test_register_defaults_to_candidate(no_such_user): - client.post("/api/v1/auth/register", json={"email": "c@d.com", "password": "hunter2hunter2"}) + client.post("/api/v1/auth/register", + json={"name": "Ada", "email": "c@d.com", "password": "hunter2hunter2"}) assert no_such_user.call_args.args[0]["role"] == "candidate" +def test_register_trims_the_name(no_such_user): + """Otherwise a padded name would sort and render as if it were something else.""" + client.post("/api/v1/auth/register", + json={"name": " Ada Lovelace ", "email": "c@d.com", + "password": "hunter2hunter2"}) + assert no_such_user.call_args.args[0]["name"] == "Ada Lovelace" + + @pytest.mark.parametrize("payload", [ - {"email": "not-an-email", "password": "hunter2hunter2"}, - {"email": "a@b.com", "password": "short"}, - {"email": "a@b.com", "password": "x" * 73}, - {"email": "a@b.com", "password": "hunter2hunter2", "role": "admin"}, - {"password": "hunter2hunter2"}, + {"name": "Ada", "email": "not-an-email", "password": "hunter2hunter2"}, + {"name": "Ada", "email": "a@b.com", "password": "short"}, + {"name": "Ada", "email": "a@b.com", "password": "x" * 73}, + {"name": "Ada", "email": "a@b.com", "password": "hunter2hunter2", "role": "admin"}, + {"name": "Ada", "password": "hunter2hunter2"}, + # A name is what the reveal has to show, so registering without a usable one + # is refused rather than accepted and papered over at display time. + {"email": "a@b.com", "password": "hunter2hunter2"}, + {"name": "", "email": "a@b.com", "password": "hunter2hunter2"}, + {"name": " ", "email": "a@b.com", "password": "hunter2hunter2"}, + {"name": "x" * 81, "email": "a@b.com", "password": "hunter2hunter2"}, ]) def test_register_validation(payload): assert client.post("/api/v1/auth/register", json=payload).status_code == 422 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9513fff..1cbc5e9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -93,8 +93,5 @@ should ever import from a `features/` folder — dependencies point inward. interview rounds elsewhere) replaces the body of that one function — it reads from quiz + application outcomes and writes to a `scores` collection via a new `score_repository.py`. Nothing else in the funnel changes when it lands. -- A real display name. `users.name` is documented and rendered but nothing - collects it yet, so a revealed profile currently falls back to showing the - email. Registration needs a name field before the reveal reads well. - `services/company_quiz_service.py` — same quiz engine, different prompt, gates job posting creation. diff --git a/frontend/src/features/auth/AuthForm.jsx b/frontend/src/features/auth/AuthForm.jsx index 7616558..573010f 100644 --- a/frontend/src/features/auth/AuthForm.jsx +++ b/frontend/src/features/auth/AuthForm.jsx @@ -4,8 +4,13 @@ import { useState } from "react"; * Shared form body for login and register. * * Deliberately plain — this gates the quiz flow, it is not a UI milestone. + * + * `signup` is the one switch between the two modes. It was `showRole` until the + * name field arrived and made it three things at once; naming it for the mode + * rather than for one of the fields keeps the next addition from repeating that. */ -export default function AuthForm({ title, submitLabel, onSubmit, showRole, footer }) { +export default function AuthForm({ title, submitLabel, onSubmit, signup, footer }) { + const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [role, setRole] = useState("candidate"); @@ -17,7 +22,7 @@ export default function AuthForm({ title, submitLabel, onSubmit, showRole, foote setBusy(true); setError(""); try { - await onSubmit({ email, password, role }); + await onSubmit({ name: name.trim(), email, password, role }); } catch (err) { setError(err.message); } finally { @@ -29,6 +34,23 @@ export default function AuthForm({ title, submitLabel, onSubmit, showRole, foote

{title}

+ {signup && ( + + )} + - {showRole && ( + {signup && (