Skip to content

Slice 2: Login + role-aware dashboard happy path #2

@mGasiorek998

Description

@mGasiorek998

What to build

A seeded patient or doctor opens the web app, lands on /login, submits their email and password, and arrives on a role-appropriate dashboard at /patient or /doctor showing "Welcome, {firstName}", a "Log out" button, and an empty "Upcoming appointments" placeholder section. Logging out clears the JWT from localStorage, resets the auth query cache, and returns the user to /login. The session survives a page refresh.

End-to-end deliverables:

Schema (Drizzle, applied via pnpm db:push)

  • user_role Postgres enum with values 'doctor', 'patient'
  • users table: id (UUID PK, generated), email (text, unique on lower-case), password_hash (text), role (user_role), created_at, updated_at
  • patients table: user_id (PK, FK → users.id ON DELETE CASCADE), first_name, last_name, updated_at
  • doctors table: user_id (PK, FK → users.id ON DELETE CASCADE), first_name, last_name, updated_at

Seed (pnpm db:seedapps/api/scripts/seed.ts)

  • Regenerate data/seed-accounts.json to use email-format addresses (firstname.lastname@medbridge.local); plaintext passwords stay committed as documented demo credentials
  • Read JSON, bcrypt each password (cost factor 12), upsert one users row plus the matching patients or doctors row by email
  • Idempotent: running twice produces the same row counts and no duplicate inserts

API (Hono, wired through requireAuth())

  • POST /auth/login — Zod-validated body { email, password }. Returns 200 + { token } on success (HS256 JWT, 24h expiry, claims sub + role + iat + exp). Returns neutral 401 + { error: 'Invalid credentials' } for wrong-creds OR unknown-email (byte-identical responses). Same Content-Type and validation gates as PRD.
  • GET /auth/me — requires requireAuth(). Re-loads the user from DB on every call. Returns 200 + { id, email, role } on success.
  • requireAuth() middleware: reads Authorization: Bearer <token>, verifies signature + expiry, loads { id, role } from DB, attaches to request context, calls next(). (The full failure-mode coverage — expired, tampered, malformed, orphaned — lands in Slice 4. This slice only needs the happy path + the "no header / wrong header" 401.)
  • Boot guard: API process reads JWT_SECRET from env at startup, exits with code 1 if missing or shorter than 32 chars
  • Use-case layer (login, getCurrentUser) sits between the routes and the DB per docs/ARCHITECTURE_RULES.md

Contracts (packages/contracts, ts-rest + Zod)

  • auth contract exporting login (POST /auth/login) and me (GET /auth/me) with full request/response/error Zod schemas

Web (apps/web)

  • /login route: controlled form (email + password). Submit calls the typed login contract. On 200, write the token via the auth-token storage wrapper (a tiny module around localStorage exposing getToken/setToken/clearToken), invalidate the ['auth', 'me'] query, and redirect to /patient or /doctor based on the freshly-fetched role.
  • /patient and /doctor routes: render "Welcome, {firstName}" + role + "Log out" button + an <section> placeholder labelled "Upcoming appointments" with empty-state copy. The first/last name comes from the role-specific profile row joined into the currentUser response (extend /auth/me if needed to include it, OR add a separate query — implementer's call as long as the dashboard renders the seeded first name).
  • The ['auth', 'me'] TanStack Query is the single source of truth for currentUser. It runs on app boot. There is no Zustand or Context store for auth.
  • Global query setup attaches the token from the storage wrapper as Authorization: Bearer <token> on every request
  • Logout button clears the token, resets the query client, navigates to /login
  • A simple beforeLoad route guard: unauthenticated user hitting any protected route → redirect to /login (full role-vs-role redirect logic is Slice 3)

Acceptance criteria

  • pnpm db:push && pnpm db:seed produces seeded users rows for every entry in data/seed-accounts.json, each with a bcrypt hash whose cost factor is 12
  • Running pnpm db:seed twice does not create duplicate rows
  • Each seeded account can log in via POST /auth/login with the email and the documented plaintext password and receive a 200 + { token }
  • The returned JWT decodes to sub (UUID matching the user), role ('doctor' or 'patient'), iat, exp (= iat + 86400)
  • POST /auth/login with a wrong password returns 401 + { error: 'Invalid credentials' }
  • POST /auth/login with an unknown email returns the byte-identical 401 + { error: 'Invalid credentials' } (verify in a test that bodies and headers match)
  • GET /auth/me with a valid Bearer token returns 200 + { id, email, role } matching the seeded user
  • The API process exits with code 1 if JWT_SECRET is missing or shorter than 32 chars (proven by a unit test)
  • apps/web /login page renders the form; submitting valid credentials lands the user on /patient (for a patient) or /doctor (for a doctor) with "Welcome, {firstName}" visible
  • After login, refreshing the page keeps the user logged in (token persisted in localStorage, /auth/me rehydrates currentUser)
  • Clicking "Log out" clears the token, resets the query cache, and lands the user on /login
  • Hitting /patient or /doctor while unauthenticated redirects to /login
  • An end-to-end Playwright test logs in as a seeded patient, asserts the welcome message on /patient, clicks "Log out", and asserts arrival on /login. A second e2e does the same for a seeded doctor on /doctor.
  • No plaintext password and no full JWT appears in any log entry written during these flows (asserted by a redaction unit test)
  • pnpm verify is green

User stories covered

  • US 1 from PRD: "As a doctor, I want to open the web app and see a login page so that I have a clear entry point to MedBridge."
  • US 2 from PRD: "As a patient, I want to log in with my email and password so that I can access my own data."
  • US 3 from PRD: "As a doctor, I want to log in with my email and password so that I can access my schedule and patient information."
  • US 4 from PRD: "As a returning patient, I want my session to survive a page refresh."
  • US 5 from PRD: "As a returning doctor, I want my session to survive a page refresh."
  • US 6 from PRD: "As a patient who lands on the patient dashboard, I want to see a placeholder 'Upcoming appointments' section."
  • US 7 from PRD: "As a doctor who lands on the doctor dashboard, I want to see a placeholder 'Upcoming appointments' section."
  • US 8 from PRD: "As a logged-in user, I want a visible logout control."
  • US 9 from PRD: "As a user who clicks 'Log out', I want my token cleared and the app to send me to the login page."
  • US 17 from PRD: "As a developer cloning the repo, I want a pnpm db:push command to apply the schema and a pnpm db:seed command to populate the seeded accounts."
  • US 20 from PRD: "As a developer working on a downstream module, I want to import a typed currentUser and a typed login/me contract from packages/contracts."
  • US 25 from PRD: "As a security-aware developer, I want the API process to refuse to boot when JWT_SECRET is missing or shorter than 32 characters."
  • US 26 from PRD: "As a security-aware developer, I want stored passwords to be bcrypt hashes with cost factor 12."
  • US 30 from PRD: "As QA running an end-to-end smoke test, I want a deterministic pnpm db:seed."

Blocked by

Metadata

Metadata

Assignees

No one assigned

    Labels

    afkEligible for the main agent looppriority:1Priority 1 — bugs default hereqa-readyPR opened, awaiting human QAsliceVertical tracer-bullet slice

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions