mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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:
@@ -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
@@ -204,6 +204,13 @@
|
||||
"when": 1782052852524,
|
||||
"tag": "0028_military_cyclops",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "7",
|
||||
"when": 1782057030557,
|
||||
"tag": "0029_tiny_starhawk",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+20
-3
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user