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
+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;