mirror of
https://github.com/temetro/temetro.git
synced 2026-08-19 06:36:21 +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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user