From c1caf0adf5414a7436e4e89697d37900e221c773 Mon Sep 17 00:00:00 2001 From: oliviabeschorner Date: Mon, 19 Oct 2026 12:17:32 -0700 Subject: [PATCH] Persistent account information --- .env.example | 6 +- AGENTS.md | 24 +- README.md | 37 ++- backend/config/settings.py | 15 +- backend/config/settings_test.py | 14 + backend/config/urls.py | 3 +- backend/medications/__init__.py | 0 backend/medications/apps.py | 6 + .../medications/migrations/0001_initial.py | 43 +++ .../0002_medication_required_fields.py | 33 +++ backend/medications/migrations/__init__.py | 0 backend/medications/models.py | 32 +++ backend/medications/urls.py | 12 + backend/medications/views.py | 162 +++++++++++ backend/pyproject.toml | 1 + backend/tests/test_account_medications.py | 143 ++++++++++ docs/specs/account-medications.md | 77 ++++++ web/src/App.tsx | 251 ++++++++++++++++-- web/src/api.ts | 96 +++++++ web/src/styles.css | 133 ++++++++-- web/vite.config.ts | 3 + 21 files changed, 1029 insertions(+), 62 deletions(-) create mode 100644 backend/config/settings_test.py create mode 100644 backend/medications/__init__.py create mode 100644 backend/medications/apps.py create mode 100644 backend/medications/migrations/0001_initial.py create mode 100644 backend/medications/migrations/0002_medication_required_fields.py create mode 100644 backend/medications/migrations/__init__.py create mode 100644 backend/medications/models.py create mode 100644 backend/medications/urls.py create mode 100644 backend/medications/views.py create mode 100644 backend/tests/test_account_medications.py create mode 100644 docs/specs/account-medications.md create mode 100644 web/src/api.ts diff --git a/.env.example b/.env.example index 3319492..316c326 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,11 @@ # Local PostgreSQL settings only. These defaults are intentionally non-secret. -# Copy to .env to override them; never commit a real .env file. +# 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 diff --git a/AGENTS.md b/AGENTS.md index e264877..a44e528 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,10 +2,10 @@ ## Current status -`infrastructure_plan.md` is the source of truth. The web tooling, Vite entrypoint, -Docker foundation, and minimal Django project scaffold are in place. Do not -introduce product pages, API handlers, domain models, authentication, or -business data 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 @@ -14,13 +14,14 @@ business data 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. -- `backend/` — Django project scaffold, Python dependency/tool configuration, and - infrastructure-only tests; no API or business application package exists yet. -- `web/` — React/Vite entrypoint, dependency/tool configuration, and - infrastructure-only tests; no product pages or business UI exists yet. +- `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/` — not created yet. - `.agents/skills/` — project-specific skills and instructions. ## Required reading and skill selection @@ -43,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 2f43f7e..6fc1727 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ ## Status -The repository has minimal Django and React/Vite application scaffolds, a local -PostgreSQL service, and pull-request checks. No production API, domain models, -or product UI 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 @@ -14,10 +14,12 @@ or product UI exists yet. readiness health check. - `scripts/docker-smoke.sh` — disposable PostgreSQL readiness and `SELECT 1` smoke test. -- `backend/` — minimal Django project scaffold, Python/Django tooling, locked - dependencies, and infrastructure probes; no API or business code exists yet. -- `web/` — React/Vite entrypoint, locked dependencies, infrastructure probes, and - the initial application shell; no product page or business UI exists yet. +- `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 @@ -57,7 +59,20 @@ or product UI exists yet. docker compose up --detach postgres ``` -6. Run the available infrastructure checks and clean up the Docker foundation: +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 + (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) + ``` + +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. + +8. Run checks and clean up the local service when finished: ```bash (cd backend && uv run ruff format --check . && uv run ruff check . && uv run pyright && uv run pytest --cov=tests) @@ -70,9 +85,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 end-to-end tests are intentionally deferred until the first product -workflow exists. A production release workflow is also 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 5916988..b4c1e9d 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -38,6 +38,7 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "medications", ] MIDDLEWARE = [ @@ -75,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"), } } @@ -121,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 bdec395..2cd3415 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -16,8 +16,9 @@ """ from django.contrib import admin -from django.urls import path +from django.urls import include, path urlpatterns = [ path("admin/", admin.site.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 1b603d7..38a9683 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -23,6 +23,7 @@ dev = [ [tool.pytest.ini_options] testpaths = ["tests"] addopts = "--strict-markers" +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 9fde9d6..7866670 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,34 +1,249 @@ +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); + + useEffect(() => { + void restoreSession(); + }, []); + + 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 handleAccountSubmit(event: FormEvent) { + event.preventDefault(); + setError(""); + setNotice(""); + setIsSubmitting(true); + + try { + 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 { + setIsSubmitting(false); + } + } + + async function handleMedicationSubmit(event: FormEvent) { + event.preventDefault(); + setError(""); + setNotice(""); + setIsSubmitting(true); + + try { + 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 { + setIsSubmitting(false); + } + } + + 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.", + ); + } + } + return (
-

Personal health workspace

- - +

Personal medication workspace

+ {user ? : null}

Medcheck

-

A clear starting point for your health records.

+

Keep your medication list close at hand.

- The React and Vite foundation is ready. Your first workflow will have - a focused home here once the product details are defined. + Save the medication name, dosage, and frequency to your private + account.

-
-

Next step

-

Define the first user workflow

-

- This shell is intentionally small: it proves the browser entrypoint, - styling pipeline, and accessible React mount without inventing - application behavior. + {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} +
  • + ))} +
+ )} +
+ ) : ( +
+

Your account

+

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

+
+ + + +
+ +
+ )}
); } 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 5ed634b..142b176 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -57,21 +57,6 @@ body::before { text-transform: uppercase; } -.status { - display: inline-flex; - align-items: center; - gap: 0.5rem; - color: #557267; - font-size: 0.875rem; -} - -.status span { - width: 0.5rem; - height: 0.5rem; - border-radius: 50%; - background: #4d8b69; -} - .intro { max-width: 48rem; padding: clamp(5rem, 15vw, 10rem) 0 4rem; @@ -95,23 +80,126 @@ body::before { line-height: 1.6; } -.next-step { +.account-card, +.workspace { max-width: 36rem; border-top: 3px solid #92b49b; padding: 1.5rem 0 0; } -.next-step h2 { +.account-card h2, +.workspace h2 { margin: 0.75rem 0; color: #203c34; font-size: 1.25rem; letter-spacing: -0.02em; } -.next-step p:last-child { - margin: 0; +form { + display: grid; + gap: 1rem; +} + +label { + display: grid; + gap: 0.4rem; + color: #314a40; + font-size: 0.9rem; + font-weight: 700; +} + +input { + width: 100%; + border: 1px solid #aeb9b2; + border-radius: 0.35rem; + background: #fffdfa; + padding: 0.7rem 0.75rem; + color: #1f2a27; + font: inherit; +} + +input:focus-visible, +button:focus-visible { + outline: 3px solid #92b49b; + outline-offset: 2px; +} + +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; +} + +button:disabled { + cursor: wait; + opacity: 0.7; +} + +.text-button { + margin-top: 1rem; + background: transparent; + padding: 0; + color: #315e4d; + text-decoration: underline; +} + +.message { + max-width: 36rem; + margin: 0 0 1.25rem; + border-left: 3px solid #4d8b69; + padding: 0.7rem 0.9rem; + background: #e9f1ea; + color: #244635; +} + +.message.error { + border-color: #a14639; + background: #f8e7e3; + color: #712d24; +} + +.section-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; +} + +.medication-form { + margin-top: 1.5rem; +} + +.medication-list { + display: grid; + gap: 0; + margin: 1.75rem 0 0; + padding: 0; + border-top: 1px solid #d9ddd5; + list-style: none; +} + +.medication-list li { + display: grid; + grid-template-columns: 1.2fr 0.8fr 1fr; + gap: 0.75rem; + border-bottom: 1px solid #d9ddd5; + padding: 1rem 0; + color: #60716b; +} + +.medication-list strong { + color: #203c34; +} + +.empty { + margin: 1.75rem 0 0; color: #60716b; - line-height: 1.6; } @media (max-width: 600px) { @@ -128,4 +216,9 @@ body::before { .intro { padding-top: 5rem; } + + .medication-list li { + grid-template-columns: 1fr; + gap: 0.3rem; + } } diff --git a/web/vite.config.ts b/web/vite.config.ts index 99a600b..5a277a1 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,5 +3,8 @@ import { defineConfig } from "vite"; export default defineConfig({ server: { port: 5173, + proxy: { + "/api": "http://127.0.0.1:8000", + }, }, });