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.
+
+ );
+ }
+
+ 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.
+
+
+
+ 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;