diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index d4be416..8a44170 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -247,6 +247,40 @@ assumed and mirrored in `models.ts` under "Phase W6 — Feature flags console". - **Validation (client-side, server presumed to re-check):** percentages must be whole numbers 0–100 (no decimals or exponents). Allowlist entries must be UUIDs, lower-cased and de-duplicated, at most 500. Ids already saved on the flag pass through unchecked, so an unexpected server-side id format can't block unrelated edits. - **Optimistic updates:** confirming writes the change into the cached list straight away, and the flag shows "Saving…" with its controls disabled until docforum-core answers. On failure only that flag is restored, and only while the cached copy still has the `updatedAt` the save started from. A list snapshot is never restored, because that would undo a save to a different flag that is in flight or already done. Every outcome then refetches the list and the audit page (without awaiting, so a cold start doesn't hold "Saving…"). - An open dialog compares its flag's `updatedAt` with the live list. If someone else saved in the meantime, it says so, blocks confirming, and offers to start again from the latest values. +### Invitation redemption — admins & facility staff (issue #81, PRD OQ-2) + +Admins and facility staff never self-register; they join through an +invite link (`/#/invite?token=…`). Everything below is ASSUMED — it +depends on docforum-core#39 (admin invites) and docforum-core#75 +(facility staff invites), neither of which has landed. Mirrored in +`src/types/models.ts` as `AccountInvite` / `InviteRole` / +`AccountInviteStatus`. + +- `GET /auth/invites/:token` (unauthenticated — the link itself is the + credential) → `AccountInvite` = `{id, email, role: 'facility'|'admin', + organisationName, organisationType?, invitedBy?, status: + 'pending'|'accepted'|'revoked'|'expired', invitedAt, expiresAt}`. + `404` = unknown token (also covers a used token the server no longer + returns); a recognised-but-consumed invite is assumed to come back as + `200` with `status: 'accepted'`. +- `POST /auth/invites/:token/accept` (body: `{email, fullName?, + password?}`) → the same `{user, accessToken}` session envelope as + signup/login. A brand-new user sends `fullName` + `password`; an + existing user accepts after logging in by sending just `{email}` with + their `Authorization: Bearer` token. `email` must match the invite + (server-checked; mismatch assumed `422`). +- **The role is NEVER in the request** — docforum-core derives it from + the token, so a tampered client cannot pick a role (PRD OQ-2). Covered + by tests, including one that appends `?role=admin` to the invite link + and asserts neither the shown role nor the accept payload changes. +- Frontend behaviour: the token is read from the URL once and + immediately stripped (`replace`), so the credential never lingers in + the address bar or browser history; the log-in round trip for existing + users carries it in React Router state only. Nothing is written to + localStorage/sessionStorage (hard rule) — reloading `/invite` shows + the invalid-link state and the person simply reopens the link from + their email. + ### Scan-to-verify orders with device camera (issue #52, docforum-core#71) - `POST /facilities/me/verify-order` (body: `{ token: string }`) → `VerifyOrderResponse` - Token verification endpoint returning 5 explicit states: `valid`, `superseded`, `expired`, `fulfilled`, `not_eligible`. diff --git a/ROADMAP.md b/ROADMAP.md index 6cd4177..0726c17 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,6 +23,19 @@ real API" (no refresh-on-reload flow). for all three roles (facility users just don't self-register). 14 tests passing (`npm test`), including one that asserts facility is never offered at signup. +- [x] Invite landing + redemption for admins and facility staff. Tracked + as [issue #81](https://github.com/DocForum/docforum-web/issues/81) + (High, 200 pts) — `InvitePage` at `/invite?token=` + + `src/features/invite/`. Validates the token, shows the inviting + organisation, role and inviter, and creates the account with the + invite's role (never a form field, never in the request payload) or + lets an already-signed-in user accept with one click (existing users + are routed through `/login` and back to the invite via router state). + Clear expired / revoked / already-used / not-found states. The token + is read from the URL once and immediately stripped. **Built against + assumed shapes:** docforum-core#39 (admin invites) and #75 (facility + staff invites) haven't landed — see `ARCHITECTURE_ESSENTIALS.md` + "Known assumptions". - [ ] Restore session on reload via `POST /auth/refresh` (single-flight refresh, one retry on `401`, `RequireAuth` waits for boot refresh). Tracked as [issue #9](https://github.com/DocForum/docforum-web/issues/9) (Medium, 150 pts). - [ ] Not done: any integration test against a real `docforum-core` instance. Tracked as [issue #12](https://github.com/DocForum/docforum-web/issues/12) (High, 200 pts) — an end-to-end smoke test against a throwaway `docforum-core` backend. @@ -378,6 +391,28 @@ Admin-only screens beyond payments. Everything goes through `docforum-core`'s ad - `#52 Scan-to-verify orders with device camera`: dedicated verification page (`/facility/verify`) enabling facility staff to scan order QR codes via device camera or enter tokens manually. Uses native `BarcodeDetector` API with fallback; guarantees complete media stream track teardown on screen unmount/exit. Dispatches to `POST /facilities/me/verify-order` and cleanly displays all 5 result states (`valid`, `superseded`, `expired`, `fulfilled`, `not_eligible`) alongside minimal clinical order summaries. Valid results feature an immediate CTA to jump into fulfillment (`/facility?orderId=...`). Surfaces HTTP 429 rate-limit protection against enumeration. - `#53 Facility payout ledger with escrow terms`: dedicated payments page (`/facility/payments`) displaying held, released, and refunded totals for both XLM and USDC. Uses BigInt-safe math (`formatStroops`) for Stellar 7-decimal stroop precision. Renders cursor-paginated escrow ledger with plain-language terms (releaser rules and refund deadline countdowns) adhering to threat model T8. Prominently flags approaching refund deadlines (< 48h) on unfulfilled orders. Integrates direct deep links to Stellar Expert testnet explorer for funding, release, and refund transactions. - Updated `ARCHITECTURE_ESSENTIALS.md` "Known assumptions" and `ROADMAP.md`. 26 test files and 124/124 tests passing; typecheck and build green. Captured desktop and mobile screenshots into `screenshots/`. +- 2026-10-19 — Built invitation redemption for admins & facility staff + (#81, PRD OQ-2): `InvitePage` at `/#/invite?token=` that validates the + token, shows the inviting organisation, role and inviter, and drives + both acceptance paths — new-user account creation (email locked to + the invite; role comes from the invite and is never a form field or a + request field) and existing-user accept (one click when already signed + in, or through `/login` and straight back to the invite via router + state). Clear expired / revoked / already-used / not-found states. The + token is read from the URL once and immediately stripped, so the + credential never lingers in the address bar or history; nothing is + persisted to storage. New: `AccountInvite`/`InviteRole` types in + `models.ts`, `getInvite`/`acceptInvite` in `auth-service.ts`, + `src/features/invite/`, the `/invite` route, and MSW mock handlers + with demo tokens (`admin-demo`, `expired-demo`, `revoked-demo`, + `used-demo`, `missing`). Tests: 13 new — invite detail/state screens, + a `?role=admin` URL-tampering test proving the role and payload stay + server-derived, the login round trip, and the bearer token on + existing-user accept — plus the signup test extended to assert + facility AND admin are never offered at self-serve signup. + `ARCHITECTURE_ESSENTIALS.md` "Known assumptions" updated with the + assumed contract (pending docforum-core#39/#75). Screenshots captured + into `screenshots/`. diff --git a/screenshots/invite-accept-signed-in-desktop.png b/screenshots/invite-accept-signed-in-desktop.png new file mode 100644 index 0000000..0354ac3 Binary files /dev/null and b/screenshots/invite-accept-signed-in-desktop.png differ diff --git a/screenshots/invite-accept-signed-in-mobile.png b/screenshots/invite-accept-signed-in-mobile.png new file mode 100644 index 0000000..90a03e2 Binary files /dev/null and b/screenshots/invite-accept-signed-in-mobile.png differ diff --git a/screenshots/invite-expired-desktop.png b/screenshots/invite-expired-desktop.png new file mode 100644 index 0000000..5f060a4 Binary files /dev/null and b/screenshots/invite-expired-desktop.png differ diff --git a/screenshots/invite-expired-mobile.png b/screenshots/invite-expired-mobile.png new file mode 100644 index 0000000..eda9e1c Binary files /dev/null and b/screenshots/invite-expired-mobile.png differ diff --git a/screenshots/invite-landing-desktop.png b/screenshots/invite-landing-desktop.png new file mode 100644 index 0000000..0b387b6 Binary files /dev/null and b/screenshots/invite-landing-desktop.png differ diff --git a/screenshots/invite-landing-mobile.png b/screenshots/invite-landing-mobile.png new file mode 100644 index 0000000..cddc37e Binary files /dev/null and b/screenshots/invite-landing-mobile.png differ diff --git a/screenshots/invite-not-found-desktop.png b/screenshots/invite-not-found-desktop.png new file mode 100644 index 0000000..6e603c8 Binary files /dev/null and b/screenshots/invite-not-found-desktop.png differ diff --git a/screenshots/invite-used-desktop.png b/screenshots/invite-used-desktop.png new file mode 100644 index 0000000..7d213b0 Binary files /dev/null and b/screenshots/invite-used-desktop.png differ diff --git a/src/App.tsx b/src/App.tsx index 1b3aca1..a458452 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { DoctorDashboardPage } from './pages/DoctorDashboardPage'; import { FacilityDashboardPage } from './pages/FacilityDashboardPage'; import { AdminDashboardPage } from './pages/AdminDashboardPage'; import { ClaimAccountPage } from './pages/ClaimAccountPage'; +import { InvitePage } from './pages/InvitePage'; import { CareThreadPage } from './pages/CareThreadPage'; import { AccountDeletedPage } from './pages/AccountDeletedPage'; import { NotFoundPage } from './pages/NotFoundPage'; @@ -31,6 +32,7 @@ export default function App() { } /> } /> } /> + } /> } /> { - it('offers only patient and doctor as self-serve roles, never facility', () => { + it('offers only patient and doctor as self-serve roles, never facility or admin', () => { render(); - // PRD OQ-2: facility accounts are admin-invited only — no self-serve - // facility signup should ever be offered here. + // PRD OQ-2: facility and admin accounts are invite-only — no + // self-serve facility/admin signup should ever be offered here. expect(screen.getByRole('radio', { name: 'Patient' })).toBeInTheDocument(); expect(screen.getByRole('radio', { name: 'Doctor' })).toBeInTheDocument(); expect(screen.queryByRole('radio', { name: /facility/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('radio', { name: /admin/i })).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/role/i)).not.toBeInTheDocument(); }); it('defaults to the patient role', () => { diff --git a/src/features/invite/components/InviteLanding.module.css b/src/features/invite/components/InviteLanding.module.css new file mode 100644 index 0000000..d5015aa --- /dev/null +++ b/src/features/invite/components/InviteLanding.module.css @@ -0,0 +1,63 @@ +.loading, +.notice { + text-align: center; + padding: var(--space-6) 0; + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.summary { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + margin-bottom: var(--space-4); +} + +.summaryRow { + display: flex; + justify-content: space-between; + gap: var(--space-3); + font-size: var(--text-sm); +} + +.summaryLabel { + color: var(--color-text-muted); +} + +.summaryValue { + color: var(--color-text); + font-weight: 600; + text-align: right; +} + +.roleBadge { + display: inline-block; + padding: 0.15rem 0.6rem; + border-radius: 999px; + background: var(--color-accent); + color: var(--color-accent-contrast, #fff); + font-size: var(--text-xs); + font-weight: 600; +} + +.emailNote { + font-size: var(--text-xs); + color: var(--color-text-muted); + margin: 0 0 var(--space-3); +} + +.form { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.acceptActions { + display: flex; + flex-direction: column; + gap: var(--space-3); +} diff --git a/src/features/invite/components/InviteLanding.test.tsx b/src/features/invite/components/InviteLanding.test.tsx new file mode 100644 index 0000000..9720f39 --- /dev/null +++ b/src/features/invite/components/InviteLanding.test.tsx @@ -0,0 +1,213 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '../../../test/render'; +import { InviteLanding } from './InviteLanding'; +import { ApiError } from '../../../services/api-client'; +import { useAuthStore } from '../../../store/auth-store'; +import * as authService from '../../../services/auth-service'; +import type { AccountInvite, User } from '../../../types/models'; + +vi.mock('../../../services/auth-service', () => ({ + getInvite: vi.fn(), + acceptInvite: vi.fn(), + login: vi.fn(), + logout: vi.fn(), +})); + +const pendingInvite: AccountInvite = { + id: 'inv-test-1', + email: 'new.facility.staff@example.test', + role: 'facility', + organisationName: 'Example Lab (fake)', + organisationType: 'lab', + invitedBy: 'Fiona Placeholder (fake)', + status: 'pending', + invitedAt: '2026-10-17T09:00:00.000Z', + expiresAt: '2026-10-31T09:00:00.000Z', +}; + +const invitedUser: User = { + id: 'usr-invited', + email: pendingInvite.email, + role: 'facility', + createdAt: '2026-10-19T09:00:00.000Z', + updatedAt: '2026-10-19T09:00:00.000Z', +}; + +describe('InviteLanding', () => { + beforeEach(() => { + vi.mocked(authService.getInvite).mockReset(); + vi.mocked(authService.acceptInvite).mockReset(); + vi.mocked(authService.logout).mockResolvedValue(undefined); + }); + + afterEach(() => { + useAuthStore.getState().clearSession(); + }); + + it('shows who invited you and to what, with no role input anywhere', async () => { + vi.mocked(authService.getInvite).mockResolvedValue(pendingInvite); + + render(); + + expect(await screen.findByText('You’ve been invited')).toBeInTheDocument(); + expect(screen.getByText('Example Lab (fake)')).toBeInTheDocument(); + expect(screen.getByText('Facility staff')).toBeInTheDocument(); + expect(screen.getByText('Fiona Placeholder (fake)')).toBeInTheDocument(); + expect(screen.getAllByText('new.facility.staff@example.test').length).toBeGreaterThan(0); + + // The role is display-only — there is no way to pick or change it here. + expect(screen.queryByRole('radio')).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/^role/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + }); + + it('shows a clear expired state', async () => { + vi.mocked(authService.getInvite).mockResolvedValue({ + ...pendingInvite, + status: 'expired', + }); + + render(); + + expect(await screen.findByText('Invitation expired')).toBeInTheDocument(); + expect(screen.getByText(/expired on /i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /accept/i })).not.toBeInTheDocument(); + }); + + it('shows a clear revoked state', async () => { + vi.mocked(authService.getInvite).mockResolvedValue({ + ...pendingInvite, + status: 'revoked', + }); + + render(); + + expect(await screen.findByText('Invitation revoked')).toBeInTheDocument(); + expect(screen.getByText(/was revoked/i)).toBeInTheDocument(); + }); + + it('shows a clear already-used state for an accepted invite', async () => { + vi.mocked(authService.getInvite).mockResolvedValue({ + ...pendingInvite, + status: 'accepted', + }); + + render(); + + expect(await screen.findByText('Invitation already used')).toBeInTheDocument(); + expect(screen.getByText(/already been used/i)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Log in' })).toBeInTheDocument(); + }); + + it('treats a 404 as a not-found/already-used link', async () => { + vi.mocked(authService.acceptInvite).mockRejectedValue(new ApiError('Not found', 404)); + vi.mocked(authService.getInvite).mockRejectedValue(new ApiError('Not found', 404)); + + render(); + + expect(await screen.findByText('Invitation not found')).toBeInTheDocument(); + expect(screen.getByText(/already been used/i)).toBeInTheDocument(); + expect(screen.queryByLabelText('Full name')).not.toBeInTheDocument(); + }); + + it('creates the account with the invited email and never sends a role', async () => { + vi.mocked(authService.getInvite).mockResolvedValue(pendingInvite); + vi.mocked(authService.acceptInvite).mockResolvedValue({ + user: invitedUser, + accessToken: 'access-token', + }); + const user = userEvent.setup(); + + render(); + + await user.type(await screen.findByLabelText('Full name'), 'New Person (fake)'); + await user.type(screen.getByLabelText('Password'), 'password123'); + await user.click(screen.getByRole('button', { name: /create account/i })); + + // Deep equality: if a `role` field ever crept into the payload, this + // assertion fails — the role is docforum-core's to derive from the token. + // The second argument is the (absent) bearer token: a brand-new + // account has no session yet. + await waitFor(() => { + expect(authService.acceptInvite).toHaveBeenCalledWith( + { + token: 'tok-1', + email: 'new.facility.staff@example.test', + fullName: 'New Person (fake)', + password: 'password123', + }, + null, + ); + }); + + await waitFor(() => { + expect(useAuthStore.getState().user?.role).toBe('facility'); + }); + }); + + it('lets an existing signed-in user accept with one click', async () => { + vi.mocked(authService.getInvite).mockResolvedValue(pendingInvite); + vi.mocked(authService.acceptInvite).mockResolvedValue({ + user: invitedUser, + accessToken: 'access-token', + }); + useAuthStore.getState().setSession(invitedUser, 'access-token'); + const user = userEvent.setup(); + + render(); + + expect(await screen.findByRole('button', { name: /accept invitation/i })).toBeInTheDocument(); + expect(screen.queryByLabelText('Password')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /accept invitation/i })); + + await waitFor(() => { + // The signed-in user's access token goes out as the bearer token — + // still no role in the payload. + expect(authService.acceptInvite).toHaveBeenCalledWith( + { token: 'tok-1', email: 'new.facility.staff@example.test' }, + 'access-token', + ); + }); + }); + + it('offers the log-in path for existing users who are signed out', async () => { + vi.mocked(authService.getInvite).mockResolvedValue(pendingInvite); + + render(); + + const loginLink = await screen.findByRole('link', { + name: /log in to accept this invitation/i, + }); + expect(loginLink).toHaveAttribute('href', '/login'); + }); + + it('flags a signed-in session for a different email and offers sign out', async () => { + vi.mocked(authService.getInvite).mockResolvedValue(pendingInvite); + vi.mocked(authService.acceptInvite).mockResolvedValue({ + user: invitedUser, + accessToken: 'access-token', + }); + useAuthStore.getState().setSession( + { ...invitedUser, email: 'wrong.person@example.test' }, + 'access-token', + ); + const user = userEvent.setup(); + + render(); + + expect(await screen.findByText(/you’re signed in as wrong.person@example.test/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /accept invitation/i })).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /sign out/i })); + + await waitFor(() => { + expect(authService.logout).toHaveBeenCalled(); + }); + // Signing out drops back into the create-account flow for the invited + // address — the session can't accept an invite meant for someone else. + expect(await screen.findByLabelText('Password')).toBeInTheDocument(); + expect(useAuthStore.getState().user).toBeNull(); + }); +}); diff --git a/src/features/invite/components/InviteLanding.tsx b/src/features/invite/components/InviteLanding.tsx new file mode 100644 index 0000000..a6f01bd --- /dev/null +++ b/src/features/invite/components/InviteLanding.tsx @@ -0,0 +1,289 @@ +// Invitation redemption (issue #81, PRD OQ-2): admins and facility staff +// never self-register — they join through an invite link. The role shown +// here and the role the account gets both come from docforum-core's +// response for this token; there is deliberately no role input anywhere +// in this flow, so a tampered client cannot pick a role. +import { useState } from 'react'; +import type { FormEvent } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { Button } from '../../../components/Button'; +import { FormError } from '../../../components/FormError'; +import { Spinner } from '../../../components/Spinner'; +import { TextField } from '../../../components/TextField'; +import { ApiError } from '../../../services/api-client'; +import { useSlowRequestHint } from '../../../hooks/useSlowRequestHint'; +import { useAuthStore } from '../../../store/auth-store'; +import { validateFullName, validatePassword } from '../../auth/validation'; +import { useLogout } from '../../auth/hooks/useLogout'; +import { useAcceptInvite, useInvite } from '../hooks/useInvite'; +import type { AccountInvite, InviteRole } from '../../../types/models'; +import styles from './InviteLanding.module.css'; + +export interface InviteLandingProps { + token: string; +} + +const ROLE_LABELS: Record = { + facility: 'Facility staff', + admin: 'Administrator', +}; + +/** Where each invited role lands after accepting — mirrors RequireAuth's + * role-gated home for that role. */ +const DASHBOARDS: Record = { + facility: '/facility', + admin: '/admin', +}; + +function formatDate(iso: string): string { + const date = new Date(iso); + return Number.isNaN(date.getTime()) + ? iso + : date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); +} + +function inviterDescription(invite: AccountInvite): string { + return invite.invitedBy ?? 'your DocForum contact'; +} + +export function InviteLanding({ token }: InviteLandingProps) { + const inviteQuery = useInvite(token); + const acceptMutation = useAcceptInvite(); + const logout = useLogout(); + const user = useAuthStore((state) => state.user); + const navigate = useNavigate(); + const isSlow = useSlowRequestHint(inviteQuery.isPending); + + const [fullName, setFullName] = useState(''); + const [password, setPassword] = useState(''); + const [fieldErrors, setFieldErrors] = useState<{ fullName?: string; password?: string }>({}); + + if (inviteQuery.isPending) { + return ( +
+

+ Checking your invitation… +

+ {isSlow && ( +

+ Still working — the server may be waking up after being idle. This can take up to a + minute. +

+ )} +
+ ); + } + + if (inviteQuery.isError) { + const error = inviteQuery.error; + // 404 = unknown token — also covers an invite the server no longer + // returns because it has already been used. + if (error instanceof ApiError && error.status === 404) { + return ( +
+

Invitation not found

+

+ This invitation link isn’t valid — it may have already been used, or the link was + copied incompletely. Ask the person who invited you to send a new link. +

+

+ Back to home +

+
+ ); + } + return ( +
+

Couldn’t check your invitation

+

+ {error instanceof ApiError ? error.message : 'Something went wrong. Please try again.'} +

+ +
+ ); + } + + const invite = inviteQuery.data; + if (!invite) return null; // Unreachable: pending/error handled above. + + if (invite.status !== 'pending') { + const headings: Record = { + pending: '', + expired: 'Invitation expired', + revoked: 'Invitation revoked', + accepted: 'Invitation already used', + }; + const messages: Record = { + pending: '', + expired: `This invitation expired on ${formatDate(invite.expiresAt)} and can no longer be used. Ask ${inviterDescription(invite)} to send you a new one.`, + revoked: `This invitation was revoked and can no longer be used. If you still need access, contact ${inviterDescription(invite)}.`, + accepted: + 'This invitation has already been used. If that was you, just log in — otherwise ask for a fresh invite.', + }; + return ( +
+

{headings[invite.status]}

+

{messages[invite.status]}

+ {invite.status === 'accepted' ? ( +

+ Log in · Back to home +

+ ) : ( +

+ Back to home +

+ )} +
+ ); + } + + const roleLabel = ROLE_LABELS[invite.role]; + const emailMatches = + user !== null && user.email.toLowerCase() === invite.email.toLowerCase(); + + const acceptError = acceptMutation.error + ? acceptMutation.error instanceof ApiError + ? acceptMutation.error.message + : 'Something went wrong. Please try again.' + : null; + + function goToDashboard() { + navigate(DASHBOARDS[invite.role], { replace: true }); + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + const errors = { + fullName: validateFullName(fullName), + password: validatePassword(password), + }; + setFieldErrors(errors); + if (errors.fullName || errors.password) return; + + // No role in the payload — docforum-core derives it from the token. + acceptMutation.mutate( + { input: { token, email: invite.email, fullName: fullName.trim(), password } }, + { onSuccess: goToDashboard }, + ); + } + + function handleAcceptExisting() { + // Existing user, already signed in: just the token + the invited + // email (so the server can verify the match), sent with their + // session. Still no role — never from the client. + acceptMutation.mutate( + { input: { token, email: invite.email } }, + { onSuccess: goToDashboard }, + ); + } + + return ( +
+

You’ve been invited

+
+
+ Organisation + {invite.organisationName} +
+ {invite.organisationType && ( +
+ Organisation type + {invite.organisationType} +
+ )} +
+ Role + + {roleLabel} + +
+
+ Invited by + {invite.invitedBy ?? 'DocForum'} +
+
+ Sent to + {invite.email} +
+
+ + {user && emailMatches && ( +
+

+ You’re signed in as {user.email}. Accepting gives this account the{' '} + {roleLabel.toLowerCase()} role for {invite.organisationName}. +

+ + +
+ )} + + {user && !emailMatches && ( +
+

+ This invitation was sent to {invite.email}, but you’re signed in as{' '} + {user.email}. Sign out and continue with the invited account to accept it. +

+ +
+ )} + + {!user && ( + <> +

+ The invitation was sent to {invite.email}. Set up your name and + password to create the account — the {roleLabel.toLowerCase()} role comes from this + invitation and can’t be chosen here. +

+
+ setFullName(e.target.value)} + error={fieldErrors.fullName} + /> + + setPassword(e.target.value)} + error={fieldErrors.password} + /> + + + +

+ Already have an account?{' '} + + Log in to accept this invitation + + . +

+ + )} +
+ ); +} diff --git a/src/features/invite/hooks/useInvite.ts b/src/features/invite/hooks/useInvite.ts new file mode 100644 index 0000000..4a90bd0 --- /dev/null +++ b/src/features/invite/hooks/useInvite.ts @@ -0,0 +1,28 @@ +// Invitation redemption hooks (issue #81). The invite token is the +// credential for the lookup; the role always comes back from +// docforum-core — never from client state. +import { useMutation, useQuery } from '@tanstack/react-query'; +import { acceptInvite, getInvite, type AcceptInviteInput } from '../../../services/auth-service'; +import { useAuthStore } from '../../../store/auth-store'; + +export function useInvite(token: string | null) { + return useQuery({ + queryKey: ['invite', token], + queryFn: () => getInvite(token!), + enabled: Boolean(token), + retry: false, + }); +} + +export function useAcceptInvite() { + const setSession = useAuthStore((state) => state.setSession); + const accessToken = useAuthStore((state) => state.accessToken); + + return useMutation({ + // An existing user accepts with their session (bearer token from the + // store); a brand-new account has no session yet, so none is sent. + mutationFn: ({ input, accessToken: override }: { input: AcceptInviteInput; accessToken?: string | null }) => + acceptInvite(input, override ?? accessToken), + onSuccess: (session) => setSession(session.user, session.accessToken), + }); +} diff --git a/src/mocks/fixtures.ts b/src/mocks/fixtures.ts index 8b0cd99..8f7d61d 100644 --- a/src/mocks/fixtures.ts +++ b/src/mocks/fixtures.ts @@ -6,6 +6,7 @@ // see ARCHITECTURE_ESSENTIALS.md "Known assumptions" for which of those // shapes are still assumed contracts. import type { + AccountInvite, Appointment, CareThread, ConsultationAddendum, @@ -663,3 +664,29 @@ export function createSeedDb(): MockDb { consultationAddenda, }; } + +// --- Invitation redemption (issue #81) -------------------------------------- +// One pending invite per demo token; handlers/auth.ts derives the other +// statuses (expired / revoked / accepted) and the 404 from the token. +export const ACCOUNT_INVITE: AccountInvite = { + id: 'inv-demo-1', + email: 'new.facility.staff@example.test', + role: 'facility', + organisationName: 'Example Lab (fake)', + organisationType: 'lab', + invitedBy: 'Fiona Placeholder (fake)', + status: 'pending', + invitedAt: '2026-10-17T09:00:00.000Z', + expiresAt: '2026-10-31T09:00:00.000Z', +}; + +/** An admin invite so the dev panel can demo the other invited role. */ +export const ADMIN_ACCOUNT_INVITE: AccountInvite = { + ...ACCOUNT_INVITE, + id: 'inv-demo-admin', + email: 'new.admin@example.test', + role: 'admin', + organisationName: 'DocForum Operations (fake)', + organisationType: null, + invitedBy: 'Alda Placeholder (fake)', +}; diff --git a/src/mocks/handlers.test.ts b/src/mocks/handlers.test.ts index 53932ad..9f32696 100644 --- a/src/mocks/handlers.test.ts +++ b/src/mocks/handlers.test.ts @@ -77,6 +77,70 @@ describe('auth', () => { dependentsService.claimAccount({ token: 't', email: 'a@example.test', password: 'x' }), ).resolves.toBeNull(); }); + + it('validates invite tokens and redeems them with the invite\'s own role', async () => { + const invite = await authService.getInvite('demo-token'); + expect(invite.role).toBe('facility'); + expect(invite.status).toBe('pending'); + + // Clear states for bad/used invites, plus the other demo statuses. + await expect(authService.getInvite('missing')).rejects.toMatchObject({ status: 404 }); + await expect(authService.getInvite('expired-demo')).resolves.toMatchObject({ + status: 'expired', + }); + await expect(authService.getInvite('used-demo')).resolves.toMatchObject({ + status: 'accepted', + }); + await expect(authService.getInvite('revoked-demo')).resolves.toMatchObject({ + status: 'revoked', + }); + + // New-account accept: the role comes from the token, not the client. + const created = await authService.acceptInvite({ + token: 'demo-token', + email: invite.email, + fullName: 'New Person (fake)', + password: 'x', + }); + expect(created.user.role).toBe('facility'); + + // Admin invite → admin session (PRD OQ-2: no self-serve admin signup). + const adminInvite = await authService.getInvite('admin-demo'); + expect(adminInvite.role).toBe('admin'); + const adminSession = await authService.acceptInvite({ + token: 'admin-demo', + email: adminInvite.email, + fullName: 'New Admin (fake)', + password: 'x', + }); + expect(adminSession.user.role).toBe('admin'); + + // Existing user accepts with their session and no password. + const accepted = await authService.acceptInvite( + { token: 'demo-token', email: invite.email }, + MOCK_ACCESS_TOKEN, + ); + expect(accepted.user.role).toBe('facility'); + await expect( + authService.acceptInvite({ token: 'demo-token', email: invite.email }), + ).rejects.toMatchObject({ status: 401 }); + + // Email must match the invite; non-pending invites are refused. + await expect( + authService.acceptInvite( + { token: 'demo-token', email: 'someone.else@example.test', password: 'x' }, + MOCK_ACCESS_TOKEN, + ), + ).rejects.toMatchObject({ status: 422 }); + await expect( + authService.acceptInvite({ + token: 'expired-demo', + email: invite.email, + fullName: 'New Person (fake)', + password: 'x', + }), + ).rejects.toMatchObject({ status: 409 }); + }); }); describe('dependents', () => { diff --git a/src/mocks/handlers/auth.ts b/src/mocks/handlers/auth.ts index 04f6be9..ed475d6 100644 --- a/src/mocks/handlers/auth.ts +++ b/src/mocks/handlers/auth.ts @@ -1,9 +1,17 @@ -// Auth endpoints (signup / login / logout / claim-account). +// Auth endpoints (signup / login / logout / claim-account / invite redemption). import { http, HttpResponse } from 'msw'; -import { ADMIN_USER, DOCTOR_USER, FACILITY_USER, MOCK_ACCESS_TOKEN, PATIENT_USER } from '../fixtures'; -import { respond } from '../helpers'; +import { + ACCOUNT_INVITE, + ADMIN_ACCOUNT_INVITE, + ADMIN_USER, + DOCTOR_USER, + FACILITY_USER, + MOCK_ACCESS_TOKEN, + PATIENT_USER, +} from '../fixtures'; +import { notFound, paramId, respond } from '../helpers'; import { useMockStore } from '../mock-store'; -import type { Role, User } from '../../types/models'; +import type { AccountInvite, Role, User } from '../../types/models'; const PERSONAS: Record = { patient: PATIENT_USER, @@ -26,6 +34,16 @@ function sessionFor(user: User) { return { user, accessToken: MOCK_ACCESS_TOKEN }; } +/** The demo invite a token maps to, or null for an unknown token (404). */ +function inviteForToken(token: string): AccountInvite | null { + if (token === 'missing') return null; + const base = token === 'admin-demo' ? ADMIN_ACCOUNT_INVITE : ACCOUNT_INVITE; + if (token === 'expired-demo') return { ...base, status: 'expired' }; + if (token === 'revoked-demo') return { ...base, status: 'revoked' }; + if (token === 'used-demo') return { ...base, status: 'accepted' }; + return base; +} + export const authHandlers = [ http.post( '*/auth/signup', @@ -76,4 +94,63 @@ export const authHandlers = [ '*/auth/claim-account', respond('void', () => null), ), + + // --- Invitation redemption (issue #81) --------------------------------- + // The token decides everything about the invite — including the role it + // grants — and any `role` in an accept body is ignored, mirroring the + // real contract where docforum-core derives the role from the token + // server-side (PRD OQ-2: no self-serve facility/admin signup). + // Demo tokens: `admin-demo` → pending admin invite; `expired-demo` / + // `revoked-demo` / `used-demo` → those statuses; `missing` → 404; any + // other token → the pending facility invite. + http.get( + '*/auth/invites/:token', + respond('json', ({ params }) => inviteForToken(paramId(params, 'token')) ?? notFound('invite')), + ), + + http.post( + '*/auth/invites/:token/accept', + respond('json', async ({ request, params }) => { + const invite = inviteForToken(paramId(params, 'token')); + if (!invite) return notFound('invite'); + + const body = (await request.json().catch(() => ({}))) as { + email?: string; + fullName?: string; + password?: string; + }; + if (!body.email) { + return HttpResponse.json({ message: 'Mock API: email is required' }, { status: 400 }); + } + if (body.email.toLowerCase() !== invite.email.toLowerCase()) { + return HttpResponse.json( + { message: 'Mock API: email does not match the invitation' }, + { status: 422 }, + ); + } + // New-account accept carries a password; an existing user accepts + // after logging in, so no password but a session is expected. + if (!body.password && !request.headers.get('authorization')) { + return HttpResponse.json( + { message: 'Mock API: log in before accepting this invitation' }, + { status: 401 }, + ); + } + if (invite.status !== 'pending') { + return HttpResponse.json( + { message: `Mock API: this invitation is ${invite.status}` }, + { status: 409 }, + ); + } + + const persona = invite.role === 'admin' ? ADMIN_USER : FACILITY_USER; + return sessionFor({ + ...persona, + id: `usr-mock-invited-${invite.role}`, + email: body.email, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }), + ), ]; diff --git a/src/pages/InvitePage.test.tsx b/src/pages/InvitePage.test.tsx new file mode 100644 index 0000000..6a36093 --- /dev/null +++ b/src/pages/InvitePage.test.tsx @@ -0,0 +1,171 @@ +// Page-level tests for the invite route (issue #81): the token is read +// once and stripped from the URL, and a tampered `?role=` query can't +// influence what role is shown or granted — it always comes from +// docforum-core's invite response. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render as rtlRender, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { InvitePage } from './InvitePage'; +import { LoginPage } from './LoginPage'; +import { useAuthStore } from '../store/auth-store'; +import * as authService from '../services/auth-service'; +import type { AccountInvite, User } from '../types/models'; + +vi.mock('../services/auth-service', () => ({ + getInvite: vi.fn(), + acceptInvite: vi.fn(), + login: vi.fn(), + logout: vi.fn(), +})); + +const pendingInvite: AccountInvite = { + id: 'inv-test-1', + email: 'new.facility.staff@example.test', + role: 'facility', + organisationName: 'Example Lab (fake)', + organisationType: 'lab', + invitedBy: 'Fiona Placeholder (fake)', + status: 'pending', + invitedAt: '2026-10-17T09:00:00.000Z', + expiresAt: '2026-10-31T09:00:00.000Z', +}; + +const invitedUser: User = { + id: 'usr-invited', + email: pendingInvite.email, + role: 'facility', + createdAt: '2026-10-19T09:00:00.000Z', + updatedAt: '2026-10-19T09:00:00.000Z', +}; + +function LocationProbe() { + const location = useLocation(); + return ( +
+ {location.pathname} + {location.search} +
+ ); +} + +function renderPage(path: string): void { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + rtlRender( + + + + + } /> + } /> + Facility dashboard} /> + Admin dashboard} /> + + + , + ); +} + +describe('InvitePage', () => { + beforeEach(() => { + vi.mocked(authService.getInvite).mockReset(); + vi.mocked(authService.acceptInvite).mockReset(); + vi.mocked(authService.login).mockReset(); + vi.mocked(authService.logout).mockResolvedValue(undefined); + }); + + afterEach(() => { + useAuthStore.getState().clearSession(); + }); + + it('reads the token from the URL, then strips it (including a tampered role param)', async () => { + vi.mocked(authService.getInvite).mockResolvedValue(pendingInvite); + + renderPage('/invite?token=secret-token&role=admin'); + + expect(await screen.findByText('Example Lab (fake)')).toBeInTheDocument(); + expect(authService.getInvite).toHaveBeenCalledWith('secret-token'); + + // The credential (and the tampered param) never linger in the URL. + await waitFor(() => { + expect(screen.getByTestId('location').textContent).toBe('/invite'); + }); + }); + + it('takes the role from the invite, never from the URL', async () => { + vi.mocked(authService.getInvite).mockResolvedValue(pendingInvite); + vi.mocked(authService.acceptInvite).mockResolvedValue({ + user: invitedUser, + accessToken: 'access-token', + }); + const user = userEvent.setup(); + + // Attacker appends role=admin to the invite link… + renderPage('/invite?token=secret-token&role=admin'); + + // …the page still shows the role docforum-core returned for the token. + expect(await screen.findByText('Facility staff')).toBeInTheDocument(); + expect(screen.queryByText('Administrator')).not.toBeInTheDocument(); + + await user.type(screen.getByLabelText('Full name'), 'New Person (fake)'); + await user.type(screen.getByLabelText('Password'), 'password123'); + await user.click(screen.getByRole('button', { name: /create account/i })); + + // …and the accept payload carries no role field at all. + await waitFor(() => { + expect(authService.acceptInvite).toHaveBeenCalledWith( + { + token: 'secret-token', + email: 'new.facility.staff@example.test', + fullName: 'New Person (fake)', + password: 'password123', + }, + null, + ); + }); + + expect(await screen.findByText('Facility dashboard')).toBeInTheDocument(); + expect(useAuthStore.getState().user?.role).toBe('facility'); + }); + + it('sends an existing user through login and back to the invite', async () => { + vi.mocked(authService.getInvite).mockResolvedValue(pendingInvite); + vi.mocked(authService.login).mockResolvedValue({ + user: invitedUser, + accessToken: 'access-token', + }); + vi.mocked(authService.acceptInvite).mockResolvedValue({ + user: invitedUser, + accessToken: 'access-token', + }); + const user = userEvent.setup(); + + renderPage('/invite?token=secret-token'); + + await user.click( + await screen.findByRole('link', { name: /log in to accept this invitation/i }), + ); + expect(screen.getByTestId('location').textContent).toBe('/login'); + + await user.type(screen.getByLabelText('Email'), 'new.facility.staff@example.test'); + await user.type(screen.getByLabelText('Password'), 'password123'); + await user.click(screen.getByRole('button', { name: /^log in$/i })); + + // Back on the invite — the token travelled in router state only, and + // the signed-in user can now accept without re-entering credentials. + expect(await screen.findByRole('button', { name: /accept invitation/i })).toBeInTheDocument(); + expect(screen.getByTestId('location').textContent).toBe('/invite'); + + await user.click(screen.getByRole('button', { name: /accept invitation/i })); + expect(await screen.findByText('Facility dashboard')).toBeInTheDocument(); + }); + + it('shows an invalid-link state when the token is missing', () => { + renderPage('/invite'); + expect(screen.getByText('Invalid link')).toBeInTheDocument(); + expect(authService.getInvite).not.toHaveBeenCalled(); + }); +}); diff --git a/src/pages/InvitePage.tsx b/src/pages/InvitePage.tsx new file mode 100644 index 0000000..83e5800 --- /dev/null +++ b/src/pages/InvitePage.tsx @@ -0,0 +1,57 @@ +// Invite landing route (issue #81): `/#/invite?token=…`. The token is +// read once — from the URL, or from router state when returning from +// /login — and immediately stripped from the URL: it's a credential and +// must not linger in the address bar, history entries, or referrers. It +// is never written to localStorage/sessionStorage (ARCHITECTURE_ESSENTIALS +// hard rule); like the session itself it only lives in memory, so a +// reload of this page shows the invalid-link state and the person simply +// reopens the link from their email. +import { useEffect, useState } from 'react'; +import { Link, useLocation, useSearchParams } from 'react-router-dom'; +import { InviteLanding } from '../features/invite/components/InviteLanding'; + +interface InviteNavState { + inviteToken?: string; +} + +export function InvitePage() { + const location = useLocation(); + const [params, setParams] = useSearchParams(); + const [urlToken] = useState(() => params.get('token')); + const stateToken = (location.state as InviteNavState | null)?.inviteToken ?? null; + const token = urlToken ?? stateToken; + + // Remove the token from the URL as soon as it has been read (issue #81 + // edge case). Router state is preserved so the log-in round trip still + // works after the query string is cleared. + useEffect(() => { + if (params.has('token')) { + setParams({}, { replace: true, state: location.state ?? null }); + } + }, [params, setParams, location.state]); + + if (!token) { + return ( +
+
+

Invalid link

+

+ This invitation link is missing or incomplete. Ask the person who invited you to send + it again. +

+

+ Back to home +

+
+
+ ); + } + + return ( +
+
+ +
+
+ ); +} diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 25d9e66..8e01e73 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -1,15 +1,32 @@ -import { Link, useNavigate } from 'react-router-dom'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; import { LoginForm } from '../features/auth/components/LoginForm'; +interface InviteNavState { + inviteToken?: string; +} + export function LoginPage() { const navigate = useNavigate(); + const location = useLocation(); + // Set when an existing user followed "Log in to accept this invitation" + // from the invite landing (issue #81) — after logging in, send them back + // to the invite instead of home. Carried in router state only, never the + // URL or storage. + const inviteToken = (location.state as InviteNavState | null)?.inviteToken ?? null; + + function handleLoginSuccess() { + navigate(inviteToken ? '/invite' : '/', { + replace: true, + state: inviteToken ? { inviteToken } : undefined, + }); + } return (

Welcome back

Log in to pick up your care thread — bookings, referrals, orders, all in one place.

- navigate('/', { replace: true })} /> +

No account? Sign up diff --git a/src/services/auth-service.ts b/src/services/auth-service.ts index 0c185a1..fba04ad 100644 --- a/src/services/auth-service.ts +++ b/src/services/auth-service.ts @@ -4,7 +4,7 @@ // implemented routes yet (its backend/src/modules/auth/* is a placeholder). // Reconcile against the real contract once docforum-core's Phase 1 lands. import { apiFetch } from './api-client'; -import type { SelfServeRole, User } from '../types/models'; +import type { AccountInvite, SelfServeRole, User } from '../types/models'; export interface AuthSession { user: User; @@ -34,3 +34,50 @@ export function login(input: LoginInput): Promise { export function logout(accessToken: string): Promise { return apiFetch('/auth/logout', { method: 'POST', accessToken }); } + +// --- Invitation redemption (issue #81) ------------------------------------- +// ASSUMED contract (docforum-core#39 admin invites, docforum-core#75 +// facility staff invites — neither landed yet). See +// ARCHITECTURE_ESSENTIALS.md "Known assumptions — Invitation redemption". + +/** + * Validate an invite token and fetch what it grants (organisation, role, + * invited by). Deliberately unauthenticated: the invite link is the + * credential. `404` = unknown token (also covers a used token the server + * no longer returns); the invite's `status` field distinguishes + * pending / accepted / revoked / expired for tokens the server still + * recognises. + */ +export function getInvite(token: string): Promise { + return apiFetch(`/auth/invites/${encodeURIComponent(token)}`); +} + +export interface AcceptInviteInput { + token: string; + /** Pre-filled from the invite — the invited email cannot be changed. */ + email: string; + /** New-account credentials. Omit when an existing user accepts after + * logging in (authenticated accept, see `accessToken` below). */ + fullName?: string; + password?: string; +} + +/** + * Redeem an invite. The role is NEVER sent: docforum-core derives it from + * the token, so a tampered client cannot pick a role here (PRD OQ-2). + * A brand-new user sends `fullName` + `password` and gets back a session + * with the invited role. An existing user accepts after logging in by + * sending just the token with their access token — `email` is still sent + * so the server can check it matches the invite. + */ +export function acceptInvite( + input: AcceptInviteInput, + accessToken?: string | null, +): Promise { + const { token, ...body } = input; + return apiFetch(`/auth/invites/${encodeURIComponent(token)}/accept`, { + method: 'POST', + body, + accessToken, + }); +} diff --git a/src/types/models.ts b/src/types/models.ts index 8d87036..74ba6bc 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -722,6 +722,32 @@ export interface FeatureFlag { updatedAt: string; } +// --- Invitation redemption — admins & facility staff (issue #81, PRD OQ-2) --- +// Admins and facility staff never self-register; they join through an +// invite link. These shapes are ASSUMED: they depend on docforum-core#39 +// (admin invites) and docforum-core#75 (facility staff invites), neither +// of which has landed. See ARCHITECTURE_ESSENTIALS.md "Known assumptions". + +/** Roles an invite may grant. Never patient/doctor — those self-serve. */ +export type InviteRole = Extract; + +export type AccountInviteStatus = 'pending' | 'accepted' | 'revoked' | 'expired'; + +export interface AccountInvite { + id: string; + email: string; + /** The role the account gets — server-side, from the invite. It is never + * a form field; the client cannot choose or change it. */ + role: InviteRole; + organisationName: string; + organisationType: string | null; + /** Full name of the person who sent the invite, if the server exposes it. */ + invitedBy: string | null; + status: AccountInviteStatus; + invitedAt: string; + expiresAt: string; +} + /** Only the fields being changed are sent. */ export interface UpdateFeatureFlagInput { enabled?: boolean;