Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3c25e6b
feat: add auth utility with SQLite user and session store
shreyagpj Jun 30, 2026
feb2c64
feat: add session-based auth API endpoints
shreyagpj Jun 30, 2026
5e7e42b
feat: expose window.clearSkills for form reset on logout
shreyagpj Jun 30, 2026
b3841af
fix: add missing desktop nav-links and wire up auth UI
shreyagpj Jun 30, 2026
762b7a8
style: add auth UI styling and fix dark mode contrast issues
shreyagpj Jun 30, 2026
f531667
fix: default _wants_json to HTML when no Accept header is present
shreyagpj Jun 30, 2026
fcc2f48
fix: correct undefined _URL_RE reference in is_valid_url
shreyagpj Jun 30, 2026
f5a4443
fix: support monkeypatch fixture in manual test runner
shreyagpj Jun 30, 2026
24089c3
test: add coverage for register, login, logout, and auth status endpo…
shreyagpj Jun 30, 2026
e8cbd16
docs: document auth feature and test-runner fix
shreyagpj Jun 30, 2026
c5e2be9
feat: adding authentication logic
shreyagpj Jul 2, 2026
0691e43
feat: add mobile buttons for authentication
shreyagpj Jul 2, 2026
738785f
fix: update functions for mobile authentication nav buttons
shreyagpj Jul 2, 2026
07bab55
feat: update learning_path.py for data persistence
shreyagpj Jul 3, 2026
a1d64d2
fix: server progress change for data persistence
shreyagpj Jul 3, 2026
d3ace3f
fix: badges and progress persistence logic
shreyagpj Jul 3, 2026
bcbb362
fix: functions update to store permenant learning path
shreyagpj Jul 3, 2026
1144406
style: mobile navbar update styling
shreyagpj Jul 3, 2026
f017d01
fix: mobile auth options alignment fix
shreyagpj Jul 3, 2026
70fe91e
feat: points earned globally stay on server
shreyagpj Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Added

- Authentication-based progress tracking — sign in / sign up, with progress, badges, and saved projects synced to a SQLite-backed account instead of the browser only
- Slide-in account dashboard showing points, activity stats, badges, and saved projects
- Saving projects and earning badges now requires being signed in
- "Share My Result" button on results page that copies a pre-filled URL to clipboard (#411)
- Auto-fill form and trigger recommendations when opening a shared URL (#411)
- Initial CHANGELOG.md setup for tracking project history
Expand All @@ -17,7 +20,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Changed

- Contributors are now expected to document user-facing changes in CHANGELOG.md
- Progress tracking moved from localStorage-only to server-backed, scoped per signed-in account

### Fixed

- No fixes recorded yet
- Manual test runner (`python tests/test_basic.py`) failing on `test_score_coverage_ratio_exact_values` because it could not supply the `monkeypatch` fixture outside of pytest
8 changes: 8 additions & 0 deletions src/errors/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ def _wants_json():
if request.path.startswith("/api/"):
return True

# No explicit Accept header (e.g. a bare test_request_context call) —
# default to HTML rather than relying on accept_mimetypes comparison,
# which can resolve differently across Werkzeug versions when the
# header is absent.
accept_header = request.headers.get("Accept", "")
if not accept_header or accept_header == "*/*":
return False

return (
request.accept_mimetypes["application/json"]
>
Expand Down
171 changes: 121 additions & 50 deletions src/routes/main_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,34 +320,42 @@ def _extract_token(req):
return req.headers.get(_TOKEN_HEADER, "").strip() or None


@main.route("/api/learning-path/<path_id>", methods=["POST"])
def create_path(path_id):
"""Create a new learning path and bind it to the supplied token.
def _resolve_owner(req):
"""Resolve the session token in the request into a stable owner key.

Request headers:
X-Learning-Path-Token (required) - the secret token chosen by the
client (should be a random UUID or similar).
auth.js sends the login session token in the X-Learning-Path-Token
header. Session tokens rotate on every login, but a learning path must
stay accessible across logins, so we translate the token into the
permanent username and use *that* as the ownership key stored/checked
in learning_path.py — never the raw, rotating token itself.

Request body (JSON):
Any JSON object representing the initial learning-path state.

Response 201: {"path_id": "<path_id>", "message": "Learning path created."}
Response 400: malformed request body or invalid path_id / token format.
Response 409: a learning path with this path_id already exists.
Returns (owner, error_response). error_response is None on success.
"""
token = _extract_token(request)
token = _extract_token(req)
if not token:
return jsonify({"error": f"'{_TOKEN_HEADER}' header is required."}), 400
return None, (jsonify({"error": f"'{_TOKEN_HEADER}' header is required."}), 400)

username = get_user_from_token(token)
if not username:
return None, (jsonify({"error": "Invalid or expired session."}), 401)

return username, None


@main.route("/api/learning-path/<path_id>", methods=["POST"])
def create_path(path_id):
owner, err = _resolve_owner(request)
if err:
return err

payload = request.get_json(silent=True)
if payload is None:
return jsonify({"error": "Request body must be valid JSON."}), 400

if not isinstance(payload, dict):
return jsonify({"error": "Request body must be a JSON object."}), 400

try:
create_learning_path(path_id, token, payload)
create_learning_path(path_id, owner, payload)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
except PathAlreadyExistsError:
Expand All @@ -358,23 +366,12 @@ def create_path(path_id):

@main.route("/api/learning-path/<path_id>", methods=["GET"])
def read_path(path_id):
"""Return the data payload for a learning path.

Request headers:
X-Learning-Path-Token (required) - the token associated with this
path when it was created.

Response 200: {"path_id": "<path_id>", "data": { ... }}
Response 400: token header missing or path_id format invalid.
Response 403: token does not match the owner token.
Response 404: no learning path found for this path_id.
"""
token = _extract_token(request)
if not token:
return jsonify({"error": f"'{_TOKEN_HEADER}' header is required."}), 400
owner, err = _resolve_owner(request)
if err:
return err

try:
data = get_learning_path(path_id, token)
data = get_learning_path(path_id, owner)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
except PathNotFoundError:
Expand All @@ -387,33 +384,18 @@ def read_path(path_id):

@main.route("/api/learning-path/<path_id>", methods=["PUT"])
def update_path(path_id):
"""Overwrite the data payload for an existing learning path.

Request headers:
X-Learning-Path-Token (required) - the token associated with this
path when it was created.

Request body (JSON):
Any JSON object representing the new learning-path state.

Response 200: {"path_id": "<path_id>", "message": "Learning path updated."}
Response 400: malformed request body, missing token, or invalid format.
Response 403: token does not match the owner token.
Response 404: no learning path found for this path_id.
"""
token = _extract_token(request)
if not token:
return jsonify({"error": f"'{_TOKEN_HEADER}' header is required."}), 400
owner, err = _resolve_owner(request)
if err:
return err

payload = request.get_json(silent=True)
if payload is None:
return jsonify({"error": "Request body must be valid JSON."}), 400

if not isinstance(payload, dict):
return jsonify({"error": "Request body must be a JSON object."}), 400

try:
update_learning_path(path_id, token, payload)
update_learning_path(path_id, owner, payload)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
except PathNotFoundError:
Expand All @@ -422,3 +404,92 @@ def update_path(path_id):
return jsonify({"error": "Forbidden: invalid token for this path."}), 403

return jsonify({"path_id": path_id, "message": "Learning path updated."}), 200


# ---------------------------------------------------------------------------
# Auth API
#
# Simple register / login / logout / me endpoints.
# On login the client gets back a session token + the user's learning-path ID
# so it can immediately sync progress via the /api/learning-path/* endpoints.
# ---------------------------------------------------------------------------
from utils.auth import (
register_user, login_user, get_user_from_token,
get_user_path_id, get_user_path_token, logout_user, AuthError
)

@main.route("/api/auth/register", methods=["POST"])
def auth_register():
"""Create a new account from a JSON body of {username, password}.

Response 201: {"token", "username", "path_id"}
Response 400: validation error or duplicate username
"""
payload = request.get_json(silent=True)
if not payload:
return jsonify({"error": "Request body must be valid JSON."}), 400

username = (payload.get("username") or "").strip()
password = (payload.get("password") or "").strip()

try:
token = register_user(username, password)
except AuthError as e:
return jsonify({"error": str(e)}), 400

path_id = get_user_path_id(username.lower())
return jsonify(
{"token": token, "username": username.lower(), "path_id": path_id}
), 201


@main.route("/api/auth/login", methods=["POST"])
def auth_login():
"""Validate credentials from a JSON body of {username, password}.

Response 200: {"token", "username", "path_id"}
Response 401: invalid username or password
"""
payload = request.get_json(silent=True)
if not payload:
return jsonify({"error": "Request body must be valid JSON."}), 400

username = (payload.get("username") or "").strip()
password = (payload.get("password") or "").strip()

try:
token = login_user(username, password)
except AuthError as e:
return jsonify({"error": str(e)}), 401

path_id = get_user_path_id(username.lower())
return jsonify(
{"token": token, "username": username.lower(), "path_id": path_id}
), 200


@main.route("/api/auth/logout", methods=["POST"])
def auth_logout():
"""Invalidate the session token sent in the X-Auth-Token header.

Response 200 always, even if the token is missing or already invalid.
"""
token = request.headers.get("X-Auth-Token", "").strip()
if token:
logout_user(token)
return jsonify({"message": "Logged out."}), 200


@main.route("/api/auth/me", methods=["GET"])
def auth_me():
"""Return the signed-in username and path_id for the given session token.

Response 200: {"username", "path_id"}
Response 401: missing or invalid X-Auth-Token header
"""
token = request.headers.get("X-Auth-Token", "").strip()
username = get_user_from_token(token)
if not username:
return jsonify({"error": "Not authenticated."}), 401
path_id = get_user_path_id(username)
return jsonify({"username": username, "path_id": path_id}), 200
Loading
Loading