You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:seed → apps/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."
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/patientor/doctorshowing "Welcome, {firstName}", a "Log out" button, and an empty "Upcoming appointments" placeholder section. Logging out clears the JWT fromlocalStorage, 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_rolePostgres enum with values'doctor','patient'userstable:id(UUID PK, generated),email(text, unique on lower-case),password_hash(text),role(user_role),created_at,updated_atpatientstable:user_id(PK, FK →users.idON DELETE CASCADE),first_name,last_name,updated_atdoctorstable:user_id(PK, FK →users.idON DELETE CASCADE),first_name,last_name,updated_atSeed (
pnpm db:seed→apps/api/scripts/seed.ts)data/seed-accounts.jsonto use email-format addresses (firstname.lastname@medbridge.local); plaintext passwords stay committed as documented demo credentialsusersrow plus the matchingpatientsordoctorsrow by emailAPI (Hono, wired through
requireAuth())POST /auth/login— Zod-validated body{ email, password }. Returns 200 +{ token }on success (HS256 JWT, 24h expiry, claimssub+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— requiresrequireAuth(). Re-loads the user from DB on every call. Returns 200 +{ id, email, role }on success.requireAuth()middleware: readsAuthorization: Bearer <token>, verifies signature + expiry, loads{ id, role }from DB, attaches to request context, callsnext(). (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.)JWT_SECRETfrom env at startup, exits with code 1 if missing or shorter than 32 charslogin,getCurrentUser) sits between the routes and the DB perdocs/ARCHITECTURE_RULES.mdContracts (
packages/contracts, ts-rest + Zod)authcontract exportinglogin(POST/auth/login) andme(GET/auth/me) with full request/response/error Zod schemasWeb (
apps/web)/loginroute: controlled form (email + password). Submit calls the typedlogincontract. On 200, write the token via theauth-tokenstorage wrapper (a tiny module aroundlocalStorageexposinggetToken/setToken/clearToken), invalidate the['auth', 'me']query, and redirect to/patientor/doctorbased on the freshly-fetched role./patientand/doctorroutes: 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 thecurrentUserresponse (extend/auth/meif needed to include it, OR add a separate query — implementer's call as long as the dashboard renders the seeded first name).['auth', 'me']TanStack Query is the single source of truth forcurrentUser. It runs on app boot. There is no Zustand or Context store for auth.Authorization: Bearer <token>on every request/loginbeforeLoadroute 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:seedproduces seededusersrows for every entry indata/seed-accounts.json, each with a bcrypt hash whose cost factor is 12pnpm db:seedtwice does not create duplicate rowsPOST /auth/loginwith the email and the documented plaintext password and receive a 200 +{ token }sub(UUID matching the user),role('doctor'or'patient'),iat,exp(=iat + 86400)POST /auth/loginwith a wrong password returns 401 +{ error: 'Invalid credentials' }POST /auth/loginwith an unknown email returns the byte-identical 401 +{ error: 'Invalid credentials' }(verify in a test that bodies and headers match)GET /auth/mewith a valid Bearer token returns 200 +{ id, email, role }matching the seeded userJWT_SECRETis missing or shorter than 32 chars (proven by a unit test)apps/web/loginpage renders the form; submitting valid credentials lands the user on/patient(for a patient) or/doctor(for a doctor) with "Welcome, {firstName}" visiblelocalStorage,/auth/merehydratescurrentUser)/login/patientor/doctorwhile unauthenticated redirects to/login/patient, clicks "Log out", and asserts arrival on/login. A second e2e does the same for a seeded doctor on/doctor.pnpm verifyis greenUser stories covered
pnpm db:pushcommand to apply the schema and apnpm db:seedcommand to populate the seeded accounts."currentUserand a typed login/me contract frompackages/contracts."JWT_SECRETis missing or shorter than 32 characters."pnpm db:seed."Blocked by