diff --git a/.env.example b/.env.example index 0f3528b..aad03f0 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,13 @@ -# Local development settings only. These defaults are intentionally non-secret. -# Copy to .env to override them; never commit a real .env file. +# Local PostgreSQL settings only. These defaults are intentionally non-secret. +# Copy to .env and replace DJANGO_SECRET_KEY; never commit a real .env file. POSTGRES_DB=medcheck POSTGRES_USER=medcheck POSTGRES_PASSWORD=medcheck_local_only POSTGRES_PORT=127.0.0.1:5432 +POSTGRES_HOST=127.0.0.1 +POSTGRES_DB_PORT=5432 +DJANGO_SECRET_KEY=replace-with-a-unique-local-development-key +DJANGO_DEBUG=true # Export these values into the shell before running Django commands locally. # Replace the development-only key outside local development. diff --git a/AGENTS.md b/AGENTS.md index af4a3d4..e4e8843 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,12 +2,10 @@ ## Current status -`infrastructure_plan.md` is the source of truth for architectural decisions. The -web tooling, MedCheck landing-page prototype, Playwright tests, Docker -foundation, minimal Django project scaffold, and initial session-based account -API are in place. The landing page only echoes a local search term; no medical -search, domain model, or saved-medicine persistence workflow exists. Do not -expand product behavior while performing infrastructure work. +`infrastructure_plan.md` is the source of truth for infrastructure decisions. +The first application slice now provides Django session authentication and +account-owned medication persistence. Keep infrastructure and product changes +within their respective boundaries. ## Repository map @@ -16,16 +14,14 @@ expand product behavior while performing infrastructure work. - `docker/backend.Dockerfile`, `docker-compose.yml`, and `.dockerignore` — Docker tooling and local PostgreSQL only. - `scripts/docker-smoke.sh` — infrastructure-owned smoke test. -- `scripts/dev.sh` — starts local Django and Vite together for account UI work. -- `backend/` — Django project scaffold, account API, Python dependency/tool - configuration, and tests; no medical business application package exists yet. -- `web/` — React/Vite entrypoint, dependency/tool configuration, and - the landing-page prototype, account forms, and My Medication navigation with - infrastructure and Playwright tests. Vite proxies `/api` to Django on port 8000. -- `.github/workflows/pr-checks.yml` — secret, backend, web, build, and browser - pull-request quality checks. +- `backend/medications/` — account/session JSON endpoints, medication model, and + migrations. Every medication query must be owner-scoped. +- `backend/tests/` — Django integration tests, including authentication and + persistence behavior. +- `web/src/` — React account and medication UI with its relative `/api` client. +- `docs/specs/account-medications.md` — approved API and persistence contract. +- `.github/workflows/pr-checks.yml` — web-only pull-request quality checks. - `.github/workflows/release.yml` — not created; it requires a selected hosting provider. -- `docs/specs/` — application and setup specifications. - `.agents/skills/` — project-specific skills and instructions. ## Required reading and skill selection @@ -48,6 +44,11 @@ Run `docker compose up --detach postgres` to start the local database and up its Compose project and volume. Never place real secrets in `.env`, images, workflow logs, or tracked files. +For application development, copy `.env.example` to an untracked `.env`, replace +`DJANGO_SECRET_KEY`, apply migrations with `uv run manage.py migrate`, run Django +on port 8000, and run Vite on port 5173. Vite proxies `/api` to Django so session +cookies and CSRF requests remain same-origin in the browser. + ## Verification and change checklist For Docker changes, run: diff --git a/README.md b/README.md index a513a7e..6e3d048 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,9 @@ ## Status -The repository has a minimal Django scaffold, a React/Vite landing-page -prototype, a local PostgreSQL service, and pull-request checks. The landing page -accepts a local search term but does not query medical information. The initial -account API provides session-based registration, login, logout, CSRF protection, -and current-user status; no medical domain models or persistent product -workflow exists yet. +Medcheck provides a private account where a user can save medication names, +dosages, and frequencies. Records are stored in PostgreSQL and remain available +after signing out and signing back in. ## Repository map @@ -17,12 +14,13 @@ workflow exists yet. readiness health check. - `scripts/docker-smoke.sh` — disposable PostgreSQL readiness and `SELECT 1` smoke test. -- `backend/` — Django project scaffold, account API, Python/Django tooling, - locked dependencies, and tests; no medical business code exists yet. -- `web/` — React/Vite landing-page prototype, locked dependencies, - infrastructure probes, and Playwright browser tests. -- `.github/workflows/pr-checks.yml` — secret scanning plus backend and web - quality checks, including the web build and Playwright tests. +- `backend/medications/` — Django medication model, JSON account/session API, + migration, and owner-scoped persistence. +- `backend/tests/` — Django integration tests for authentication, persistence, + and account isolation. +- `web/src/` — React account and medication-list interface plus its API client. +- `docs/specs/account-medications.md` — the account-medication contract. +- `.github/workflows/pr-checks.yml` — web-only infrastructure quality checks. - `.github/dependabot.yml` — weekly dependency-update configuration. - `infrastructure_plan.md` — the approved infrastructure plan and source of truth for future configuration. @@ -72,18 +70,20 @@ workflow exists yet. docker compose up --detach postgres ``` -6. Start the local application and account service together: +6. Apply local database migrations, then start the backend and browser client in + separate terminals. The `set -a` commands export the non-secret local values + from `.env` for each process. ```bash - ./scripts/dev.sh + (cd backend && set -a && source ../.env && set +a && uv run manage.py migrate) + (cd backend && set -a && source ../.env && set +a && uv run manage.py runserver) + (cd web && pnpm dev) ``` - Open `http://127.0.0.1:5173`. Press Ctrl+C in that terminal to stop both - the Django API and Vite web server. The script runs Django migrations using - `.env` when available, or the safe values in `.env.example`. +7. Open `http://127.0.0.1:5173`, create an account, save a medication, sign out, + then sign back in to confirm the record persists. -7. Run the available backend, web, and browser checks, then clean up the Docker - foundation: +8. Run checks and clean up the local service when finished: ```bash (cd backend && uv run python manage.py check && uv run ruff format --check . && uv run ruff check . && uv run pyright && uv run pytest --cov=tests && uv run bandit --quiet --recursive accounts config) @@ -96,9 +96,9 @@ The local database is exposed only on `127.0.0.1:5432` by default. Change `POSTGRES_PORT` in an untracked `.env` if that port is occupied. Reset local database data with `docker compose down --volumes`. -Playwright currently verifies the landing-page layout and local search-form -behavior. A production release workflow remains deferred until a hosting -provider is selected. +The application uses Django session cookies and CSRF protection; do not place +authentication tokens in browser storage. A production release workflow remains +deferred until a hosting provider is selected. ## Troubleshooting diff --git a/backend/config/settings.py b/backend/config/settings.py index cf71d05..b4c1e9d 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -38,7 +38,7 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - "accounts", + "medications", ] MIDDLEWARE = [ @@ -76,8 +76,12 @@ DATABASES = { "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("POSTGRES_DB", "medcheck"), + "USER": os.environ.get("POSTGRES_USER", "medcheck"), + "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "medcheck_local_only"), + "HOST": os.environ.get("POSTGRES_HOST", "127.0.0.1"), + "PORT": os.environ.get("POSTGRES_DB_PORT", "5432"), } } @@ -122,3 +126,9 @@ # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = "Lax" +SESSION_COOKIE_SECURE = not DEBUG +CSRF_COOKIE_SAMESITE = "Lax" +CSRF_COOKIE_SECURE = not DEBUG diff --git a/backend/config/settings_test.py b/backend/config/settings_test.py new file mode 100644 index 0000000..0d60b31 --- /dev/null +++ b/backend/config/settings_test.py @@ -0,0 +1,14 @@ +"""Settings used only by the Django test suite.""" + +import os + +os.environ.setdefault("DJANGO_SECRET_KEY", "test-only-secret-key") + +from .settings import * # noqa: F403 + +DATABASES = { # noqa: F405 + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } +} diff --git a/backend/config/urls.py b/backend/config/urls.py index 09847b8..2cd3415 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -20,5 +20,5 @@ urlpatterns = [ path("admin/", admin.site.urls), - path("api/auth/", include("accounts.urls")), + path("api/", include("medications.urls")), ] diff --git a/backend/medications/__init__.py b/backend/medications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/medications/apps.py b/backend/medications/apps.py new file mode 100644 index 0000000..6b84f81 --- /dev/null +++ b/backend/medications/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MedicationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "medications" diff --git a/backend/medications/migrations/0001_initial.py b/backend/medications/migrations/0001_initial.py new file mode 100644 index 0000000..22232b1 --- /dev/null +++ b/backend/medications/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.17 on 2026-10-14 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Medication", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=120)), + ("dosage", models.CharField(max_length=120)), + ("frequency", models.CharField(max_length=120)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="medications", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={"ordering": ["-created_at"]}, + ), + ] diff --git a/backend/medications/migrations/0002_medication_required_fields.py b/backend/medications/migrations/0002_medication_required_fields.py new file mode 100644 index 0000000..231e8bf --- /dev/null +++ b/backend/medications/migrations/0002_medication_required_fields.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.17 on 2026-10-19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("medications", "0001_initial"), + ] + + operations = [ + migrations.AddConstraint( + model_name="medication", + constraint=models.CheckConstraint( + condition=models.Q(("name", ""), _negated=True), + name="medication_name_not_blank", + ), + ), + migrations.AddConstraint( + model_name="medication", + constraint=models.CheckConstraint( + condition=models.Q(("dosage", ""), _negated=True), + name="medication_dosage_not_blank", + ), + ), + migrations.AddConstraint( + model_name="medication", + constraint=models.CheckConstraint( + condition=models.Q(("frequency", ""), _negated=True), + name="medication_frequency_not_blank", + ), + ), + ] diff --git a/backend/medications/migrations/__init__.py b/backend/medications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/medications/models.py b/backend/medications/models.py new file mode 100644 index 0000000..de6c47d --- /dev/null +++ b/backend/medications/models.py @@ -0,0 +1,32 @@ +from django.conf import settings +from django.db import models +from django.db.models import Q + + +class Medication(models.Model): + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="medications", + ) + name = models.CharField(max_length=120) + dosage = models.CharField(max_length=120) + frequency = models.CharField(max_length=120) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + constraints = [ + models.CheckConstraint( + condition=~Q(name=""), + name="medication_name_not_blank", + ), + models.CheckConstraint( + condition=~Q(dosage=""), + name="medication_dosage_not_blank", + ), + models.CheckConstraint( + condition=~Q(frequency=""), + name="medication_frequency_not_blank", + ), + ] diff --git a/backend/medications/urls.py b/backend/medications/urls.py new file mode 100644 index 0000000..f8ba7cc --- /dev/null +++ b/backend/medications/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("csrf/", views.csrf_token), + path("auth/register/", views.register), + path("auth/login/", views.login_view), + path("auth/logout/", views.logout_view), + path("auth/me/", views.current_user), + path("medications/", views.medications), +] diff --git a/backend/medications/views.py b/backend/medications/views.py new file mode 100644 index 0000000..edb7e7b --- /dev/null +++ b/backend/medications/views.py @@ -0,0 +1,162 @@ +import hashlib +import json +from functools import wraps + +from django.contrib.auth import authenticate, get_user_model, login, logout +from django.contrib.auth.password_validation import validate_password +from django.core.cache import cache +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.db import IntegrityError, transaction +from django.http import HttpRequest, HttpResponse, JsonResponse +from django.middleware.csrf import get_token +from django.views.decorators.csrf import ensure_csrf_cookie +from django.views.decorators.http import require_GET, require_http_methods, require_POST + +from .models import Medication + +MAX_LOGIN_ATTEMPTS = 10 +LOGIN_WINDOW_SECONDS = 15 * 60 +MEDICATION_FIELDS = ("name", "dosage", "frequency") + + +def error(code: str, message: str, status: int) -> JsonResponse: + return JsonResponse({"error": {"code": code, "message": message}}, status=status) + + +def json_body(request: HttpRequest) -> dict[str, str] | None: + try: + payload = json.loads(request.body) + except (json.JSONDecodeError, UnicodeDecodeError): + return None + if not isinstance(payload, dict) or not all( + isinstance(value, str) for value in payload.values() + ): + return None + return payload + + +def user_data(user) -> dict[str, int | str]: + return {"id": user.id, "email": user.email} + + +def medication_data(medication: Medication) -> dict[str, int | str]: + return { + "id": medication.id, + "name": medication.name, + "dosage": medication.dosage, + "frequency": medication.frequency, + } + + +def api_login_required(view): + @wraps(view) + def wrapped(request: HttpRequest, *args, **kwargs): + if not request.user.is_authenticated: + return error("AUTHENTICATION_REQUIRED", "Sign in to continue.", 401) + return view(request, *args, **kwargs) + + return wrapped + + +def login_rate_limited(request: HttpRequest) -> bool: + client_ip = request.META.get("REMOTE_ADDR", "unknown") + key = f"login-attempts:{hashlib.sha256(client_ip.encode()).hexdigest()}" + attempts = cache.get(key, 0) + if attempts >= MAX_LOGIN_ATTEMPTS: + return True + cache.add(key, 0, LOGIN_WINDOW_SECONDS) + return False + + +def record_failed_login(request: HttpRequest) -> None: + client_ip = request.META.get("REMOTE_ADDR", "unknown") + key = f"login-attempts:{hashlib.sha256(client_ip.encode()).hexdigest()}" + cache.add(key, 0, LOGIN_WINDOW_SECONDS) + cache.incr(key) + + +@require_GET +@ensure_csrf_cookie +def csrf_token(request: HttpRequest) -> JsonResponse: + return JsonResponse({"csrfToken": get_token(request)}) + + +@require_POST +def register(request: HttpRequest) -> JsonResponse: + payload = json_body(request) + if payload is None: + return error("VALIDATION_ERROR", "Submit a JSON object.", 400) + + email = payload.get("email", "").strip().lower() + password = payload.get("password", "") + try: + validate_email(email) + validate_password(password) + except ValidationError: + return error("VALIDATION_ERROR", "Enter a valid email and stronger password.", 400) + + try: + with transaction.atomic(): + user = get_user_model().objects.create_user( + username=email, + email=email, + password=password, + ) + except IntegrityError: + return error("EMAIL_UNAVAILABLE", "That email is already registered.", 409) + + login(request, user) + return JsonResponse({"user": user_data(user)}, status=201) + + +@require_POST +def login_view(request: HttpRequest) -> JsonResponse: + if login_rate_limited(request): + return error("RATE_LIMITED", "Try again later.", 429) + + payload = json_body(request) + if payload is None: + return error("VALIDATION_ERROR", "Submit a JSON object.", 400) + + email = payload.get("email", "").strip().lower() + password = payload.get("password", "") + user = authenticate(request, username=email, password=password) + if user is None: + record_failed_login(request) + return error("INVALID_CREDENTIALS", "Email or password is incorrect.", 401) + + login(request, user) + return JsonResponse({"user": user_data(user)}) + + +@require_POST +@api_login_required +def logout_view(request: HttpRequest) -> HttpResponse: + logout(request) + return HttpResponse(status=204) + + +@require_GET +@api_login_required +def current_user(request: HttpRequest) -> JsonResponse: + return JsonResponse({"user": user_data(request.user)}) + + +@require_http_methods(["GET", "POST"]) +@api_login_required +def medications(request: HttpRequest) -> JsonResponse: + if request.method == "GET": + records = Medication.objects.filter(owner=request.user) + return JsonResponse({"data": [medication_data(record) for record in records]}) + + payload = json_body(request) + if payload is None: + return error("VALIDATION_ERROR", "Submit a JSON object.", 400) + + values = {field: payload.get(field, "").strip() for field in MEDICATION_FIELDS} + if any(not value or len(value) > 120 for value in values.values()): + return error("VALIDATION_ERROR", "Name, dosage, and frequency are required.", 400) + + medication = Medication.objects.create(owner=request.user, **values) + return JsonResponse(medication_data(medication), status=201) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 79676ff..38a9683 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -23,7 +23,7 @@ dev = [ [tool.pytest.ini_options] testpaths = ["tests"] addopts = "--strict-markers" -DJANGO_SETTINGS_MODULE = "config.settings" +DJANGO_SETTINGS_MODULE = "config.settings_test" [tool.coverage.run] branch = true diff --git a/backend/tests/test_account_medications.py b/backend/tests/test_account_medications.py new file mode 100644 index 0000000..751b255 --- /dev/null +++ b/backend/tests/test_account_medications.py @@ -0,0 +1,143 @@ +import json +from typing import Any, cast + +import pytest +from django.contrib.auth import get_user_model +from django.db import IntegrityError, transaction +from django.test import Client + +from medications.models import Medication + +TEST_PASSWORD = "a-safe-password-123" # nosec B105 + + +def csrf_client() -> tuple[Client, str]: + client = Client(enforce_csrf_checks=True) + return client, csrf_token(client) + + +def csrf_token(client: Client) -> str: + response = cast(Any, client.get("/api/csrf/")) + assert response.status_code == 200 # nosec B101 + return response.json()["csrfToken"] + + +def post_json(client: Client, path: str, payload: dict[str, str], token: str): + return cast( + Any, + client.post( + path, + data=json.dumps(payload), + content_type="application/json", + HTTP_X_CSRFTOKEN=token, + ), + ) + + +def get(client: Client, path: str) -> Any: + return cast(Any, client.get(path)) + + +@pytest.mark.django_db +def test_user_can_sign_in_add_a_medication_and_see_it_after_signing_in_again(): + client, token = csrf_client() + + registration = post_json( + client, + "/api/auth/register/", + {"email": "patient@example.com", "password": TEST_PASSWORD}, + token, + ) + assert registration.status_code == 201 # nosec B101 + token = csrf_token(client) + + creation = post_json( + client, + "/api/medications/", + {"name": "Lisinopril", "dosage": "10 mg", "frequency": "Once daily"}, + token, + ) + assert creation.status_code == 201 # nosec B101 + assert creation.json()["name"] == "Lisinopril" # nosec B101 + + logout = post_json(client, "/api/auth/logout/", {}, token) + assert logout.status_code == 204 # nosec B101 + + login = post_json( + client, + "/api/auth/login/", + {"email": "patient@example.com", "password": TEST_PASSWORD}, + token, + ) + assert login.status_code == 200 # nosec B101 + + medications = get(client, "/api/medications/") + assert medications.status_code == 200 # nosec B101 + assert medications.json()["data"] == [ # nosec B101 + { + "id": creation.json()["id"], + "name": "Lisinopril", + "dosage": "10 mg", + "frequency": "Once daily", + } + ] + + +@pytest.mark.django_db +def test_user_cannot_read_another_users_medications(): + first_client, first_token = csrf_client() + post_json( + first_client, + "/api/auth/register/", + {"email": "first@example.com", "password": TEST_PASSWORD}, + first_token, + ) + post_json( + first_client, + "/api/medications/", + {"name": "Metformin", "dosage": "500 mg", "frequency": "Twice daily"}, + first_token, + ) + + second_client, second_token = csrf_client() + post_json( + second_client, + "/api/auth/register/", + {"email": "second@example.com", "password": TEST_PASSWORD}, + second_token, + ) + + medications = get(second_client, "/api/medications/") + assert medications.status_code == 200 # nosec B101 + assert medications.json()["data"] == [] # nosec B101 + + +@pytest.mark.django_db +def test_private_medication_endpoint_rejects_anonymous_requests(): + response = get(Client(), "/api/medications/") + + assert response.status_code == 401 # nosec B101 + assert response.json()["error"]["code"] == "AUTHENTICATION_REQUIRED" # nosec B101 + + +@pytest.mark.django_db +@pytest.mark.parametrize("field", ["name", "dosage", "frequency"]) +def test_database_rejects_medications_with_blank_required_fields(field: str): + user = get_user_model().objects.create_user( + username="patient@example.com", + email="patient@example.com", + password=TEST_PASSWORD, + ) + values = { + "name": "Lisinopril", + "dosage": "10 mg", + "frequency": "Once daily", + } + values[field] = "" + + with pytest.raises(IntegrityError): + with cast(Any, transaction.atomic)(): + cast(Any, Medication).objects.create( + owner=user, + **values, + ) diff --git a/docs/specs/account-medications.md b/docs/specs/account-medications.md new file mode 100644 index 0000000..3ad01e1 --- /dev/null +++ b/docs/specs/account-medications.md @@ -0,0 +1,77 @@ +# Spec: Account medications + +## Objective + +Give an individual a private account where they can register, sign in, add +medications, sign out, and sign in again to see the same records. The first +medication record contains a name, dosage, and frequency. + +## Tech stack + +- Django 5.2 with its built-in user model, password hashing, sessions, and + CSRF protection. +- PostgreSQL for local and hosted persistent data; Django's test database is + isolated from the developer database. +- React 19 and Vite 6 for the browser interface. + +## Commands + +```bash +docker compose up --detach postgres +(cd backend && set -a && source ../.env && set +a && uv run manage.py migrate) +(cd backend && set -a && source ../.env && set +a && uv run manage.py runserver) +(cd web && pnpm dev) +(cd backend && uv run pytest) +(cd web && pnpm typecheck && pnpm build) +``` + +## API contract + +All endpoints are relative to `/api/`, use JSON, and return errors as +`{"error": {"code": "...", "message": "..."}}`. + +| Method | Path | Request | Result | +| --- | --- | --- | --- | +| GET | `/api/csrf/` | — | Sets CSRF cookie and returns `{ "csrfToken": "..." }` | +| POST | `/api/auth/register/` | `email`, `password` | Creates and signs in account; `201` user | +| POST | `/api/auth/login/` | `email`, `password` | Signs in account; `200` user | +| POST | `/api/auth/logout/` | — | Clears the session; `204` | +| GET | `/api/auth/me/` | — | Current user or `401` | +| GET | `/api/medications/` | — | Current user's medications, newest first | +| POST | `/api/medications/` | `name`, `dosage`, `frequency` | Creates an account-owned medication; `201` | + +Mutating requests include the CSRF token in `X-CSRFToken`. Unauthenticated +requests to private endpoints receive `401`; they never redirect to HTML. + +## Structure and style + +- `backend/medications/` owns the medication model, API views, URLs, and + backend tests. +- `web/src/` owns the form-driven account and medication interface. +- `docs/specs/` records product contracts that inform both clients. +- Django models use `snake_case`; API JSON and React types use `camelCase`. + +## Testing strategy + +- Django integration tests prove registration, login/logout, validation, + ownership isolation, and records surviving a new session. +- The browser flow proves the accessible React form can register, add a + medication, sign out, sign back in, and display the persisted record. + +## Boundaries + +- Always: validate JSON at the API boundary, require a valid session for + medication access, enforce CSRF for writes, and use Django migrations. +- Ask first: new medication fields, external identity providers, password-reset + email, or exposing medications to another account. +- Never: store passwords or auth tokens in browser storage, return another + user's records, commit a secret, or use medication data as medical advice. + +## Success criteria + +1. A new user can register with email and password, then sign out and sign in. +2. A signed-in user can save name, dosage, and frequency for a medication. +3. The same user sees that medication after a sign-out/sign-in cycle. +4. A different user receives no records belonging to the first user. +5. Invalid input, missing CSRF tokens, and anonymous private requests receive + safe JSON errors. diff --git a/web/src/App.tsx b/web/src/App.tsx index e920cca..7866670 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,388 +1,249 @@ -import { FormEvent, useEffect, useRef, useState } from "react"; -import { Navigate, NavLink, Route, Routes } from "react-router-dom"; -import { Account, AccountError, accountAction, currentAccount } from "./auth"; +import { FormEvent, useEffect, useState } from "react"; + +import { + ApiError, + createMedication, + currentUser, + listMedications, + Medication, + register, + signIn, + signOut, + User, +} from "./api"; + +type AccountMode = "sign-in" | "register"; + +const EMPTY_MEDICATION = { name: "", dosage: "", frequency: "" }; + +export function App() { + const [user, setUser] = useState(null); + const [medications, setMedications] = useState([]); + const [mode, setMode] = useState("sign-in"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [medication, setMedication] = useState(EMPTY_MEDICATION); + const [notice, setNotice] = useState(""); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); -type OpenPanel = "menu" | "account" | null; - -export type SavedMedicine = { - id: string; - name: string; -}; - -type AppProps = { - savedMedicines?: SavedMedicine[]; -}; + useEffect(() => { + void restoreSession(); + }, []); -function Navigation({ - account, - onAccountChange, - ready, -}: { - account: Account | null; - onAccountChange: (account: Account | null) => void; - ready: boolean; -}) { - const [openPanel, setOpenPanel] = useState(null); - const [accountForm, setAccountForm] = useState<"login" | "register" | null>( - null, - ); - const [errors, setErrors] = useState>({}); - const [busy, setBusy] = useState(false); - const menuButtonRef = useRef(null); + async function restoreSession() { + try { + const signedInUser = await currentUser(); + setUser(signedInUser); + setMedications(await listMedications()); + } catch (caught) { + if (!(caught instanceof ApiError) || caught.status !== 401) { + setError("We could not load your account. Try refreshing the page."); + } + } + } - async function submitAccount(event: FormEvent) { + async function handleAccountSubmit(event: FormEvent) { event.preventDefault(); - if (!accountForm || busy) return; - const form = event.currentTarget; - const values = Object.fromEntries(new FormData(form).entries()) as Record< - string, - string - >; - setBusy(true); - setErrors({}); + setError(""); + setNotice(""); + setIsSubmitting(true); + try { - const nextAccount = await accountAction(accountForm, values); - onAccountChange(nextAccount); - setOpenPanel(null); - setAccountForm(null); - } catch (error) { - setErrors( - error instanceof AccountError - ? error.errors - : { - form: - error instanceof Error ? error.message : "Please try again.", - }, + const signedInUser = + mode === "register" + ? await register(email, password) + : await signIn(email, password); + setUser(signedInUser); + setPassword(""); + setMedications(await listMedications()); + setNotice( + mode === "register" ? "Your account is ready." : "Welcome back.", ); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Unable to sign in."); } finally { - setBusy(false); + setIsSubmitting(false); } } - async function signOut() { - setBusy(true); - setErrors({}); + async function handleMedicationSubmit(event: FormEvent) { + event.preventDefault(); + setError(""); + setNotice(""); + setIsSubmitting(true); + try { - await accountAction("logout"); - onAccountChange(null); - setOpenPanel(null); - } catch (error) { - setErrors({ - form: error instanceof Error ? error.message : "Please try again.", - }); + const saved = await createMedication(medication); + setMedications((current) => [saved, ...current]); + setMedication(EMPTY_MEDICATION); + setNotice(`${saved.name} was saved to your account.`); + } catch (caught) { + setError( + caught instanceof Error ? caught.message : "Unable to save medication.", + ); } finally { - setBusy(false); + setIsSubmitting(false); } } - useEffect(() => { - if (openPanel !== "menu") { - return; - } - - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - - function closeMenu(event: KeyboardEvent) { - if (event.key === "Escape") { - setOpenPanel(null); - menuButtonRef.current?.focus(); - } + async function handleSignOut() { + setError(""); + setNotice(""); + try { + await signOut(); + setUser(null); + setMedications([]); + setNotice("You are signed out. Your medications remain saved."); + } catch (caught) { + setError( + caught instanceof Error ? caught.message : "Unable to sign out.", + ); } - - document.addEventListener("keydown", closeMenu); - return () => { - document.removeEventListener("keydown", closeMenu); - document.body.style.overflow = previousOverflow; - }; - }, [openPanel]); + } return ( -
- - - - {openPanel === "menu" && ( - - - ) : accountForm ? ( -
-

{accountForm === "login" ? "Sign in" : "Create account"}

- {errors.form && ( -

- {errors.form} -

- )} - +
+
+

Personal medication workspace

+ {user ? : null} +
+ +
+

Medcheck

+

Keep your medication list close at hand.

+

+ Save the medication name, dosage, and frequency to your private + account. +

+
+ + {error ? ( +

+ {error} +

+ ) : null} + {notice ? ( +

+ {notice} +

+ ) : null} + + {user ? ( +
+
+
+

Signed in as {user.email}

+

Your medications

+
+
+ + + + + + + {medications.length === 0 ? ( +

+ No medications saved yet. +

) : ( - <> - - - +
    + {medications.map((item) => ( +
  • + {item.name} + {item.dosage} + {item.frequency} +
  • + ))} +
)} - - )} -
- ); -} - -function HomePage() { - const [query, setQuery] = useState(""); - const [submittedQuery, setSubmittedQuery] = useState(""); - - function handleSubmit(event: FormEvent) { - event.preventDefault(); - const trimmedQuery = query.trim(); - - if (trimmedQuery) { - setSubmittedQuery(trimmedQuery); - } - } - - return ( -
-

MedCheck

-

- Your simple starting point for health information. -

- -
- - setQuery(event.target.value)} - placeholder="Search medications, symptoms, or health topics" - autoComplete="off" - /> - -
- -

- {submittedQuery ? `Searching for “${submittedQuery}”` : ""} -

-
- ); -} - -function SavedMedicinesPage({ medicines }: { medicines: SavedMedicine[] }) { - return ( -
-
-

Your library

-

My Medication

-

Medicines you save will be collected here for quick reference.

-
- - {medicines.length === 0 ? ( -
- -

No saved medicines yet.

-

Return Home to search when you are ready to build your list.

- - Return Home - -
+
) : ( -
    - {medicines.map((medicine) => ( -
  • {medicine.name}
  • - ))} -
+
+

Your account

+

+ {mode === "register" ? "Create an account" : "Sign in"} +

+
+ + + +
+ +
)} - - ); -} - -export function App({ savedMedicines = [] }: AppProps) { - const [account, setAccount] = useState(null); - const [accountReady, setAccountReady] = useState(false); - - useEffect(() => { - let active = true; - currentAccount().then( - (user) => { - if (active) { - setAccount(user); - setAccountReady(true); - } - }, - () => { - if (active) setAccountReady(true); - }, - ); - return () => { - active = false; - }; - }, []); - - return ( -
- - - } /> - - ) : ( - - ) - } - /> - } - /> -
); } diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..2507570 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,96 @@ +export type User = { + id: number; + email: string; +}; + +export type Medication = { + id: number; + name: string; + dosage: string; + frequency: string; +}; + +type ErrorResponse = { + error?: { + message?: string; + }; +}; + +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + } +} + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`/api/${path}`, { + credentials: "same-origin", + ...init, + }); + const body = response.status === 204 ? null : await response.json(); + + if (!response.ok) { + const error = body as ErrorResponse; + throw new ApiError( + error.error?.message ?? "Something went wrong.", + response.status, + ); + } + + return body as T; +} + +async function csrfHeaders(): Promise { + const { csrfToken } = await request<{ csrfToken: string }>("csrf/"); + return { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken, + }; +} + +async function post(path: string, payload?: object): Promise { + return request(path, { + method: "POST", + headers: await csrfHeaders(), + body: payload ? JSON.stringify(payload) : undefined, + }); +} + +export async function currentUser(): Promise { + const { user } = await request<{ user: User }>("auth/me/"); + return user; +} + +export async function register(email: string, password: string): Promise { + const { user } = await post<{ user: User }>("auth/register/", { + email, + password, + }); + return user; +} + +export async function signIn(email: string, password: string): Promise { + const { user } = await post<{ user: User }>("auth/login/", { + email, + password, + }); + return user; +} + +export function signOut(): Promise { + return post("auth/logout/"); +} + +export async function listMedications(): Promise { + const { data } = await request<{ data: Medication[] }>("medications/"); + return data; +} + +export function createMedication( + input: Omit, +): Promise { + return post("medications/", input); +} diff --git a/web/src/styles.css b/web/src/styles.css index 83b36c3..3a1967b 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -194,256 +194,150 @@ input { text-transform: uppercase; } -.popover button { - border: 0; - border-radius: 0.375rem; - background: transparent; - color: #1d332b; - cursor: pointer; - padding: 0.5rem; - text-align: left; -} - -.popover button:hover, -.popover button:focus-visible { - background: #edf3ee; - outline: 0; -} - -.icon-button { - display: grid; - width: 2.75rem; - height: 2.75rem; - place-items: center; - border: 1px solid transparent; - border-radius: 0.5rem; - background: transparent; - color: #557267; - cursor: pointer; - transition: - border-color 160ms ease, - background-color 160ms ease, - color 160ms ease; +.intro { + max-width: 48rem; + padding: clamp(5rem, 15vw, 10rem) 0 4rem; } -.top-bar > .icon-button:first-child { - position: relative; - z-index: 30; -} - -.icon-button:hover { - border-color: #c7d2ca; - background: #fffefa; +.section-label { + margin: 0 0 0.75rem; color: #2f6b4f; + font-size: 0.75rem; + font-weight: 750; + letter-spacing: 0.08em; + text-transform: uppercase; } -.icon-button:focus-visible { - outline: 3px solid #9bc4a7; - outline-offset: 2px; -} - -.icon-button svg { - width: 1.5rem; - height: 1.5rem; - fill: currentColor; -} - -.hamburger-icon { - display: grid; - width: 1.25rem; - gap: 0.25rem; -} - -.hamburger-icon span { - display: block; - height: 0.125rem; - border-radius: 999px; - background: currentColor; -} - -.search-panel { - display: flex; - width: min(100%, 42rem); - align-items: center; - flex-direction: column; - text-align: center; -} - -h1 { - margin: 0; - color: #1d332b; - font-size: clamp(3rem, 9vw, 5.5rem); - font-weight: 650; - letter-spacing: -0.07em; - line-height: 1; +.saved-medicines h1 { + font-size: clamp(2.5rem, 7vw, 4.5rem); } -.tagline { - margin: 1rem 0 2.25rem; +.saved-medicines-heading > p:last-child { + margin: 1rem 0 0; color: #60716b; - font-size: clamp(1rem, 2vw, 1.125rem); - line-height: 1.5; -} - -.search-form { - display: flex; - width: 100%; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.5rem 0.5rem 1.25rem; - border: 1px solid #c7d2ca; - border-radius: 0.75rem; - background: #fffefa; - box-shadow: 0 0.75rem 2rem rgb(44 72 57 / 8%); - transition: - border-color 160ms ease, - box-shadow 160ms ease; -} - -.search-form:focus-within { - border-color: #6c9a7d; - box-shadow: 0 0 0 0.25rem rgb(108 154 125 / 18%); + font-size: 1.0625rem; + line-height: 1.6; } -.search-form input { - min-width: 0; - flex: 1; - border: 0; - outline: 0; - background: transparent; - color: #1d332b; - font-size: 1rem; +.account-card, +.workspace { + max-width: 36rem; + border-top: 3px solid #92b49b; + padding: 1.5rem 0 0; } -.search-form input::placeholder { - color: #84918a; +.account-card h2, +.workspace h2 { + margin: 0.75rem 0; + color: #203c34; + font-size: 1.25rem; } -.search-form button { +form { display: grid; - width: 2.75rem; - height: 2.75rem; - flex: 0 0 auto; - place-items: center; - border: 0; - border-radius: 0.5rem; - background: #2f6b4f; - color: #fffefa; - cursor: pointer; - transition: - background-color 160ms ease, - transform 160ms ease; + gap: 1rem; } -.search-form button:hover { - background: #24563f; +label { + display: grid; + gap: 0.4rem; + color: #314a40; + font-size: 0.9rem; + font-weight: 700; } -.search-form button:active { - transform: scale(0.96); +input { + width: 100%; + border: 1px solid #aeb9b2; + border-radius: 0.35rem; + background: #fffdfa; + padding: 0.7rem 0.75rem; + color: #1f2a27; + font: inherit; } -.search-form button:focus-visible { - outline: 3px solid #9bc4a7; +input:focus-visible, +button:focus-visible { + outline: 3px solid #92b49b; outline-offset: 2px; } -.search-form svg { - width: 1.25rem; - height: 1.25rem; - fill: none; - stroke: currentColor; - stroke-linecap: round; - stroke-linejoin: round; - stroke-width: 1.75; +button { + width: fit-content; + border: 0; + border-radius: 0.35rem; + background: #203c34; + padding: 0.7rem 1rem; + color: #fff; + cursor: pointer; + font: inherit; + font-weight: 700; } -.search-status { - min-height: 1.5rem; - margin: 1rem 0 0; - color: #557267; - font-size: 0.875rem; +button:disabled { + cursor: wait; + opacity: 0.7; } -.saved-medicines { - width: min(100%, 50rem); - padding: 6rem 0 3rem; +.text-button { + margin-top: 1rem; + background: transparent; + padding: 0; + color: #315e4d; + text-decoration: underline; } -.saved-medicines-heading { - max-width: 38rem; +.message { + max-width: 36rem; + margin: 0 0 1.25rem; + border-left: 3px solid #4d8b69; + padding: 0.7rem 0.9rem; + background: #e9f1ea; + color: #244635; } -.section-label { - margin: 0 0 0.75rem; - color: #2f6b4f; - font-size: 0.75rem; - font-weight: 750; - letter-spacing: 0.08em; - text-transform: uppercase; +.message.error { + border-color: #a14639; + background: #f8e7e3; + color: #712d24; } -.saved-medicines h1 { - font-size: clamp(2.5rem, 7vw, 4.5rem); +.section-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; } -.saved-medicines-heading > p:last-child { - margin: 1rem 0 0; - color: #60716b; - font-size: 1.0625rem; - line-height: 1.6; +.medication-form { + margin-top: 1.5rem; } -.empty-state { +.medication-list { display: grid; - justify-items: start; - margin-top: 3rem; - padding: 2rem 0; - border-top: 1px solid #c7d2ca; - border-bottom: 1px solid #c7d2ca; + gap: 0; + margin: 1.75rem 0 0; + padding: 0; + border-top: 1px solid #d9ddd5; + list-style: none; } -.empty-state-icon { +.medication-list li { display: grid; - width: 2.75rem; - height: 2.75rem; - place-items: center; - border-radius: 50%; - background: #dce9df; - color: #2f6b4f; - font-size: 1.75rem; - font-weight: 400; - line-height: 1; -} - -.empty-state h2 { - margin: 1.25rem 0 0; - color: #1d332b; - font-size: 1.25rem; -} - -.empty-state p { - margin: 0.5rem 0 0; + grid-template-columns: 1.2fr 0.8fr 1fr; + gap: 0.75rem; + border-bottom: 1px solid #d9ddd5; + padding: 1rem 0; color: #60716b; - line-height: 1.5; -} - -.home-link { - margin-top: 1.5rem; - border-bottom: 1px solid currentColor; - color: #2f6b4f; - font-weight: 700; - text-decoration: none; } -.home-link:hover { - color: #24563f; +.medication-list strong { + color: #203c34; } -.home-link:focus-visible { - border-radius: 0.25rem; - outline: 3px solid #9bc4a7; - outline-offset: 0.25rem; +.empty { + margin: 1.75rem 0 0; + color: #60716b; } .medicine-list { @@ -504,7 +398,8 @@ h1 { padding-top: 5rem; } - .empty-state { - margin-top: 2rem; + .medication-list li { + grid-template-columns: 1fr; + gap: 0.3rem; } } diff --git a/web/vite.config.ts b/web/vite.config.ts index 76494c1..5a277a1 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ server: { port: 5173, proxy: { - "/api": { target: "http://127.0.0.1:8000", changeOrigin: false }, + "/api": "http://127.0.0.1:8000", }, }, });