feat: QR pairing for wallet import (backend + website)

- Backend: nullable wallet_number on wallet_share_requests (migration 0029);
  createPairingRequest + POST /api/patients/wallet/pair; applyShareResponse
  binds the authenticated wallet on QR (pairing) requests
- Frontend: Import dialog gains a "Show QR" mode rendering a temetro-pair QR
  (react-qr-code) the patient scans; requestWalletPairing helper; i18n keys

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-21 21:31:51 +03:00
parent 2d47abcc42
commit 7a359d4911
11 changed files with 4529 additions and 24 deletions
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "wallet_share_requests" ALTER COLUMN "wallet_number" DROP NOT NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -204,6 +204,13 @@
"when": 1782052852524,
"tag": "0028_military_cyclops",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1782057030557,
"tag": "0029_tiny_starhawk",
"breakpoints": true
}
]
}
+4 -1
View File
@@ -27,7 +27,10 @@ export const walletShareRequests = pgTable(
requestedBy: text("requested_by")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
walletNumber: text("wallet_number").notNull(),
// Null for QR "scan to connect" pairing requests — the wallet number is
// bound when the authenticated device responds. Set up-front for the
// type-the-number push flow.
walletNumber: text("wallet_number"),
ephemeralPubKey: text("ephemeral_pub_key").notNull(),
// Encrypted (lib/crypto.ts) hex of the ephemeral X25519 private key.
ephemeralPrivEnc: text("ephemeral_priv_enc").notNull(),
+27
View File
@@ -27,6 +27,33 @@ const requestSchema = z.object({
durationHours: z.number().positive().max(8760).optional(),
});
const pairSchema = z.object({
mode: z.enum(["permanent", "temporary"]).default("permanent"),
durationHours: z.number().positive().max(8760).optional(),
});
// Create a QR pairing request (no wallet number yet). Returns the request id +
// the ephemeral public key the device seals its bundle to; the clinic encodes
// both — plus its own relay URL — into the QR the patient scans.
patientsWalletRouter.post(
"/pair",
requirePermission({ patient: ["write"] }),
async (req, res, next) => {
try {
const input = pairSchema.parse(req.body);
const { view, ephemeralPubKey } = await walletShare.createPairingRequest(
req.organizationId!,
req.user!.id,
input.mode,
input.durationHours,
);
res.status(201).json({ ...view, ephemeralPubKey });
} catch (err) {
next(err);
}
},
);
// Start an import: validate the wallet number, mint a per-request ephemeral key,
// and relay an encrypted-share request to the patient's device. The clinician
// then polls the request until the patient approves on their phone.
+45 -4
View File
@@ -33,7 +33,7 @@ export type ShareRequestView = {
function toView(row: ShareRow): ShareRequestView {
return {
id: row.id,
walletNumber: row.walletNumber,
walletNumber: row.walletNumber ?? "",
status: row.status,
shareMode: row.shareMode,
shareExpiresAt: row.shareExpiresAt ? row.shareExpiresAt.toISOString() : null,
@@ -41,6 +41,35 @@ function toView(row: ShareRow): ShareRequestView {
};
}
// Create a QR "scan to connect" pairing request — no wallet number yet (it is
// bound when the scanning device responds). Mints the ephemeral keypair the
// device seals to; the pairing URI (relay + id + ephemeral key) goes in the QR.
export async function createPairingRequest(
orgId: string,
userId: string,
mode: WalletShareMode,
durationHours?: number,
): Promise<{ view: ShareRequestView; ephemeralPubKey: string }> {
const { privateKeyHex, publicKeyHex } = newEncryptionKeypair();
const shareExpiresAt =
mode === "temporary" && durationHours
? new Date(Date.now() + durationHours * 3_600_000)
: null;
const [row] = await db
.insert(walletShareRequests)
.values({
organizationId: orgId,
requestedBy: userId,
walletNumber: null,
ephemeralPubKey: publicKeyHex,
ephemeralPrivEnc: encryptSecret(privateKeyHex),
shareMode: mode,
shareExpiresAt,
})
.returning();
return { view: toView(row!), ephemeralPubKey: publicKeyHex };
}
// Create an import request: validate the wallet number, mint a per-request
// ephemeral X25519 keypair (the phone seals the bundle to its public key) and
// store the request. Returns the row + the ephemeral public key to relay to the
@@ -125,12 +154,19 @@ export async function applyShareResponse(
.from(walletShareRequests)
.where(eq(walletShareRequests.id, requestId));
if (!row || row.status !== "pending") return null;
if (row.walletNumber !== walletNumber.trim()) return null;
// Typed flow: the responder must match the wallet the clinic addressed.
// QR pairing flow (stored wallet number is null): bind it to the
// authenticated responder now.
if (row.walletNumber && row.walletNumber !== walletNumber.trim()) return null;
if (decision === "denied") {
const [updated] = await db
.update(walletShareRequests)
.set({ status: "denied", resolvedAt: new Date() })
.set({
status: "denied",
resolvedAt: new Date(),
walletNumber: walletNumber.trim(),
})
.where(eq(walletShareRequests.id, requestId))
.returning();
return updated ? toView(updated) : null;
@@ -150,7 +186,12 @@ export async function applyShareResponse(
const [updated] = await db
.update(walletShareRequests)
.set({ status: "approved", resolvedAt: new Date(), draft: bundle.patient })
.set({
status: "approved",
resolvedAt: new Date(),
draft: bundle.patient,
walletNumber: walletNumber.trim(),
})
.where(eq(walletShareRequests.id, requestId))
.returning();
return updated ? toView(updated) : null;
@@ -1,8 +1,9 @@
"use client";
import { Check, Loader2, Smartphone, X } from "lucide-react";
import { Check, Loader2, QrCode, Smartphone, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import QRCodeSvg from "react-qr-code";
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
import { Button } from "@/components/ui/button";
@@ -18,11 +19,12 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { ApiError } from "@/lib/api-client";
import { ApiError, API_BASE_URL } from "@/lib/api-client";
import {
commitWalletShare,
type Patient,
pollWalletShare,
requestWalletPairing,
requestWalletShare,
type WalletShareRequest,
} from "@/lib/patients";
@@ -38,6 +40,8 @@ type Phase =
| "expired"
| "error";
type Mode = "number" | "qr";
const DURATIONS = [
{ hours: 1, key: "hours", count: 1 },
{ hours: 24, key: "days", count: 1 },
@@ -57,12 +61,14 @@ export function ImportFromWalletDialog({
onImported?: (fileNumber: string) => void;
}) {
const { t } = useTranslation();
const [mode, setMode] = useState<Mode>("number");
const [walletNumber, setWalletNumber] = useState("");
const [temporary, setTemporary] = useState(false);
const [durationHours, setDurationHours] = useState<number>(24);
const [phase, setPhase] = useState<Phase>("form");
const [error, setError] = useState<string | null>(null);
const [request, setRequest] = useState<WalletShareRequest | null>(null);
const [pairUri, setPairUri] = useState<string | null>(null);
const [reviewOpen, setReviewOpen] = useState(false);
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -76,12 +82,14 @@ export function ImportFromWalletDialog({
// Reset everything whenever the dialog is (re)opened.
useEffect(() => {
if (open) {
setMode("number");
setWalletNumber("");
setTemporary(false);
setDurationHours(24);
setPhase("form");
setError(null);
setRequest(null);
setPairUri(null);
setReviewOpen(false);
}
return stopPolling;
@@ -140,6 +148,32 @@ export function ImportFromWalletDialog({
}
};
// QR flow: create a pairing request (no wallet number) and build the
// `temetro-pair:` URI the app scans (this clinic's relay URL + request + key).
const startPairing = async () => {
setPhase("requesting");
setError(null);
try {
const pairing = await requestWalletPairing({
mode: temporary ? "temporary" : "permanent",
durationHours: temporary ? durationHours : undefined,
});
const params = new URLSearchParams({
relay: API_BASE_URL,
rid: pairing.id,
epk: pairing.ephemeralPubKey,
mode: pairing.shareMode,
});
if (temporary) params.set("dur", String(durationHours));
setPairUri(`temetro-pair:?${params.toString()}`);
setRequest(pairing);
setPhase("waiting");
} catch {
setError(t("patients.importApp.error"));
setPhase("error");
}
};
const commitDraft = async (record: Patient) => {
if (!request) return;
try {
@@ -177,7 +211,20 @@ export function ImportFromWalletDialog({
</DialogHeader>
<DialogPanel className="flex flex-col gap-4">
{phase === "waiting" ? (
{phase === "waiting" && mode === "qr" && pairUri ? (
<div className="flex flex-col items-center gap-3 py-2 text-center">
<div className="rounded-2xl bg-white p-4">
<QRCodeSvg value={pairUri} size={196} />
</div>
<p className="font-medium text-sm">
{t("patients.importApp.qrCaption")}
</p>
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
{t("patients.importApp.waitingTitle")}
</p>
</div>
) : phase === "waiting" ? (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
<p className="font-medium text-sm">
@@ -212,18 +259,47 @@ export function ImportFromWalletDialog({
</div>
) : (
<>
<label className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">
{t("patients.importApp.walletLabel")}
</span>
<Input
autoFocus
disabled={phase === "requesting"}
onChange={(e) => setWalletNumber(e.target.value)}
placeholder={t("patients.importApp.walletPlaceholder")}
value={walletNumber}
/>
</label>
<div className="flex gap-1 rounded-2xl bg-muted/50 p-1">
<Button
className="flex-1 rounded-xl"
onClick={() => setMode("number")}
size="sm"
type="button"
variant={mode === "number" ? "default" : "ghost"}
>
<Smartphone className="size-4" />
{t("patients.importApp.tabNumber")}
</Button>
<Button
className="flex-1 rounded-xl"
onClick={() => setMode("qr")}
size="sm"
type="button"
variant={mode === "qr" ? "default" : "ghost"}
>
<QrCode className="size-4" />
{t("patients.importApp.tabQr")}
</Button>
</div>
{mode === "number" ? (
<label className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">
{t("patients.importApp.walletLabel")}
</span>
<Input
autoFocus
disabled={phase === "requesting"}
onChange={(e) => setWalletNumber(e.target.value)}
placeholder={t("patients.importApp.walletPlaceholder")}
value={walletNumber}
/>
</label>
) : (
<p className="text-sm text-muted-foreground">
{t("patients.importApp.qrHint")}
</p>
)}
<div className="flex items-start justify-between gap-4 rounded-2xl border border-border bg-card/30 p-3">
<div className="space-y-0.5">
@@ -282,7 +358,8 @@ export function ImportFromWalletDialog({
? t("patients.importApp.close")
: t("patients.importApp.cancel")}
</DialogClose>
{phase === "form" || phase === "requesting" || phase === "error" ? (
{(phase === "form" || phase === "requesting" || phase === "error") &&
mode === "number" ? (
<Button
disabled={!walletNumber.trim() || phase === "requesting"}
onClick={sendRequest}
@@ -292,6 +369,17 @@ export function ImportFromWalletDialog({
? t("patients.importApp.requesting")
: t("patients.importApp.request")}
</Button>
) : (phase === "form" || phase === "requesting" || phase === "error") &&
mode === "qr" ? (
<Button
disabled={phase === "requesting"}
onClick={startPairing}
type="button"
>
{phase === "requesting"
? t("patients.importApp.requesting")
: t("patients.importApp.generateQr")}
</Button>
) : null}
</DialogFooter>
</DialogPopup>
@@ -186,6 +186,11 @@
"importApp": {
"title": "Import from a patient app",
"description": "Enter the patient's wallet number. They'll get a request on their phone to approve sharing their record with this clinic.",
"tabNumber": "By number",
"tabQr": "Show QR",
"qrHint": "Generate a QR code, then have the patient scan it in their wallet app to share their record.",
"qrCaption": "Scan this in the patient app",
"generateQr": "Show QR code",
"walletLabel": "Patient wallet number",
"walletPlaceholder": "tmw_…",
"tempLabel": "Share temporarily",
+15
View File
@@ -213,6 +213,21 @@ export async function requestWalletShare(input: {
});
}
// Create a QR pairing request (no wallet number — the patient scans a QR that
// carries this clinic's relay URL + the request). Returns the request plus the
// ephemeral public key the device seals to.
export type WalletPairing = WalletShareRequest & { ephemeralPubKey: string };
export async function requestWalletPairing(input: {
mode: WalletShareMode;
durationHours?: number;
}): Promise<WalletPairing> {
return apiFetch<WalletPairing>("/api/patients/wallet/pair", {
method: "POST",
body: JSON.stringify(input),
});
}
// Poll a request until the patient approves/denies on their device.
export async function pollWalletShare(
id: string,
+20 -3
View File
@@ -55,6 +55,7 @@
"react-dom": "19.2.4",
"react-i18next": "^17.0.8",
"react-jsx-parser": "^2.4.1",
"react-qr-code": "^2.2.0",
"shadcn": "^4.8.3",
"shiki": "^4.1.0",
"socket.io-client": "^4.8.3",
@@ -10580,7 +10581,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -12805,7 +12805,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -12985,6 +12984,12 @@
"node": ">=6"
}
},
"node_modules/qrcode-generator": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
"integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
"license": "MIT"
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
@@ -13122,7 +13127,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-jsx-parser": {
@@ -13167,6 +13171,19 @@
"@types/react": "^18.0.0"
}
},
"node_modules/react-qr-code": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.2.0.tgz",
"integrity": "sha512-e5nS0UUN22K3Nf8KBRUzemfdJ6OmnN5w+kbnj1lvJaol9RyVRFeGl05bCkxSN2ZegbLxjjYjX1+mmAoX9+fAhw==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.1",
"qrcode-generator": "^2.0.4"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
+1
View File
@@ -56,6 +56,7 @@
"react-dom": "19.2.4",
"react-i18next": "^17.0.8",
"react-jsx-parser": "^2.4.1",
"react-qr-code": "^2.2.0",
"shadcn": "^4.8.3",
"shiki": "^4.1.0",
"socket.io-client": "^4.8.3",