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,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",