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/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..981c6cd 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -9,15 +9,24 @@ "email": str, # stored lowercased; unique, see note below "hashed_password": str, # bcrypt, never the plaintext "role": "candidate" | "employer", + "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), } 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/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/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..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,8 +23,14 @@ 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 + # 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..ee089fa --- /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"], + # 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"), + "revealed": True, + } diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py index 90e4617..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,21 +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={"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/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..1cbc5e9 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,11 @@ 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. - `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/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 && (