Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions ARCHITECTURE_ESSENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
35 changes: 35 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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/`.



Binary file added screenshots/invite-accept-signed-in-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/invite-accept-signed-in-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/invite-expired-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/invite-expired-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/invite-landing-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/invite-landing-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/invite-not-found-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/invite-used-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -31,6 +32,7 @@ export default function App() {
<Route path="login" element={<LoginPage />} />
<Route path="signup" element={<SignupPage />} />
<Route path="claim-account" element={<ClaimAccountPage />} />
<Route path="invite" element={<InvitePage />} />
<Route path="account-deleted" element={<AccountDeletedPage />} />
<Route
path="care/*"
Expand Down
8 changes: 5 additions & 3 deletions src/features/auth/components/SignupForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ import { render, screen } from '../../../test/render';
import { SignupForm } from './SignupForm';

describe('SignupForm', () => {
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(<SignupForm />);

// 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', () => {
Expand Down
63 changes: 63 additions & 0 deletions src/features/invite/components/InviteLanding.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading