diff --git a/.env.example b/.env.example index 6669464..03eec9b 100644 --- a/.env.example +++ b/.env.example @@ -85,4 +85,9 @@ USE_AWS_SECRETS=false AWS_SECRET_NAME= # AWS region for Secrets Manager (default: us-east-1) -AWS_REGION=us-east-1 \ No newline at end of file +AWS_REGION=us-east-1 + +# ── Patient consent ─────────────────────────────────────────────────────────── +# Set to 'false' to waive consent requirement (e.g. jurisdiction config). +# Default: true (consent required before minting) +REQUIRE_PATIENT_CONSENT=true diff --git a/backend/src/indexer/db.js b/backend/src/indexer/db.js index 5d23a2c..866cbeb 100644 --- a/backend/src/indexer/db.js +++ b/backend/src/indexer/db.js @@ -28,6 +28,11 @@ const SCHEMA = ` created_at TEXT NOT NULL, revoked INTEGER NOT NULL DEFAULT 0 ); + + CREATE TABLE IF NOT EXISTS patient_consent ( + wallet TEXT PRIMARY KEY, + consented_at TEXT NOT NULL + ); `; /** Persist in-memory DB to disk. */ @@ -139,4 +144,27 @@ function revokeApiKey(id) { flush(); } -module.exports = { initDb, upsertEvents, queryEvents, getLatestLedger, insertApiKey, getApiKeyByHash, listApiKeys, revokeApiKey }; +module.exports = { initDb, upsertEvents, queryEvents, getLatestLedger, insertApiKey, getApiKeyByHash, listApiKeys, revokeApiKey, getConsent, recordConsent, hasConsented }; + +// ── Patient consent ─────────────────────────────────────────────────────────── + +function recordConsent(wallet) { + db.run( + 'INSERT OR IGNORE INTO patient_consent (wallet, consented_at) VALUES (?, ?)', + [wallet, new Date().toISOString()] + ); + flush(); +} + +function getConsent(wallet) { + const res = db.exec('SELECT wallet, consented_at FROM patient_consent WHERE wallet = ?', [wallet]); + if (!res.length) return null; + const { columns, values } = res[0]; + const obj = {}; + columns.forEach((col, i) => { obj[col] = values[0][i]; }); + return obj; +} + +function hasConsented(wallet) { + return !!getConsent(wallet); +} diff --git a/backend/src/routes/consent.js b/backend/src/routes/consent.js new file mode 100644 index 0000000..9225e7c --- /dev/null +++ b/backend/src/routes/consent.js @@ -0,0 +1,30 @@ +const express = require('express'); +const authMiddleware = require('../middleware/auth'); +const { getConsent, recordConsent, hasConsented } = require('../indexer/db'); + +const router = express.Router(); + +/** + * GET /patient/consent/:wallet + * Returns consent status for a wallet. Public — used by issuers before minting. + */ +router.get('/consent/:wallet', (req, res) => { + const { wallet } = req.params; + const consent = getConsent(wallet); + res.json({ wallet, consented: !!consent, consented_at: consent?.consented_at ?? null }); +}); + +/** + * POST /patient/consent + * Record explicit patient consent. Requires patient JWT. + */ +router.post('/consent', authMiddleware, (req, res) => { + const { publicKey } = req.user; + if (getConsent(publicKey)) { + return res.json({ success: true, wallet: publicKey, already_consented: true }); + } + recordConsent(publicKey); + res.status(201).json({ success: true, wallet: publicKey }); +}); + +module.exports = router; diff --git a/backend/src/routes/vaccination.js b/backend/src/routes/vaccination.js index 3da9df1..4296261 100644 --- a/backend/src/routes/vaccination.js +++ b/backend/src/routes/vaccination.js @@ -7,6 +7,7 @@ const { validateStellarPublicKey } = require('../middleware/wallet'); const { invokeContract, simulateContract } = require('../stellar/soroban'); const { audit } = require('../middleware/auditLog'); const validate = require('../middleware/validate'); +const { hasConsented } = require('../indexer/db'); const router = express.Router(); @@ -98,6 +99,11 @@ router.post( async (req, res) => { const { patient_address, vaccine_name, date_administered, dose_number, dose_series } = req.body; + // Enforce patient consent unless jurisdiction config waives it + if (process.env.REQUIRE_PATIENT_CONSENT !== 'false' && !hasConsented(patient_address)) { + return res.status(403).json({ error: 'Patient has not provided consent. They must consent before a record can be issued.' }); + } + try { const toOptU32 = (v) => v != null ? StellarSdk.xdr.ScVal.scvVec([StellarSdk.xdr.ScVal.scvU32(v)]) diff --git a/frontend/src/components/ConsentScreen.jsx b/frontend/src/components/ConsentScreen.jsx new file mode 100644 index 0000000..6207e9e --- /dev/null +++ b/frontend/src/components/ConsentScreen.jsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; + +const s = { + overlay: { + position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', + display: 'flex', alignItems: 'center', justifyContent: 'center', + zIndex: 1000, padding: '1rem', + }, + modal: { + background: '#1e293b', borderRadius: 12, padding: '2rem', + maxWidth: 520, width: '100%', color: '#e2e8f0', + }, + title: { fontSize: '1.25rem', fontWeight: 700, marginBottom: '1rem', color: '#38bdf8' }, + body: { fontSize: '0.9rem', lineHeight: 1.6, color: '#94a3b8', marginBottom: '1.25rem' }, + list: { paddingLeft: '1.25rem', marginBottom: '1.25rem', color: '#94a3b8', fontSize: '0.9rem', lineHeight: 1.8 }, + checkRow: { display: 'flex', alignItems: 'flex-start', gap: '0.75rem', marginBottom: '1.5rem' }, + checkLabel: { fontSize: '0.9rem', color: '#e2e8f0', cursor: 'pointer' }, + btnRow: { display: 'flex', gap: '0.75rem', justifyContent: 'flex-end' }, + btnAccept: { padding: '0.6rem 1.5rem', background: '#0ea5e9', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontWeight: 600 }, + btnDecline: { padding: '0.6rem 1.5rem', background: 'transparent', color: '#94a3b8', border: '1px solid #334155', borderRadius: 8, cursor: 'pointer' }, +}; + +/** + * ConsentScreen — shown to first-time patients before their wallet is associated + * with on-chain vaccination records. + * + * Props: + * onAccept() — called when patient accepts; caller should POST /v1/patient/consent + * onDecline() — called when patient declines + * loading — disables the accept button while the consent POST is in flight + */ +export default function ConsentScreen({ onAccept, onDecline, loading = false }) { + const [checked, setChecked] = useState(false); + + return ( +
+
+ +

+ Before your vaccination records can be issued, you must understand what data is stored + and who can access it. +

+ +
+ setChecked(e.target.checked)} + aria-required="true" + /> + +
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/hooks/useConsent.js b/frontend/src/hooks/useConsent.js new file mode 100644 index 0000000..cc6fafa --- /dev/null +++ b/frontend/src/hooks/useConsent.js @@ -0,0 +1,55 @@ +import { useState, useCallback } from 'react'; +import { useAuth } from './useFreighter'; + +/** + * useConsent — manages patient consent state. + * + * Returns: + * consented — null (unknown), true, or false + * checkConsent() — fetch consent status for the connected wallet + * giveConsent() — POST consent and update state + * loading — true while a request is in flight + */ +export function useConsent() { + const { publicKey, apiFetch } = useAuth(); + const [consented, setConsented] = useState(null); + const [loading, setLoading] = useState(false); + + const checkConsent = useCallback(async (wallet) => { + if (!wallet) return; + setLoading(true); + try { + const res = await apiFetch(`/v1/patient/consent/${wallet}`); + const data = await res.json(); + setConsented(data.consented); + return data.consented; + } catch { + return null; + } finally { + setLoading(false); + } + }, [apiFetch]); + + const giveConsent = useCallback(async () => { + if (!publicKey) return false; + setLoading(true); + try { + const res = await apiFetch('/v1/patient/consent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ wallet: publicKey }), + }); + if (res.ok) { + setConsented(true); + return true; + } + return false; + } catch { + return false; + } finally { + setLoading(false); + } + }, [publicKey, apiFetch]); + + return { consented, checkConsent, giveConsent, loading }; +} diff --git a/frontend/src/pages/PatientDashboard.jsx b/frontend/src/pages/PatientDashboard.jsx index ef907d2..5c2b768 100644 --- a/frontend/src/pages/PatientDashboard.jsx +++ b/frontend/src/pages/PatientDashboard.jsx @@ -7,6 +7,7 @@ import NFTCardSkeleton from '../components/NFTCardSkeleton'; import RecordDetailModal from '../components/RecordDetailModal'; import CopyButton from '../components/CopyButton'; import QRCodeModal from '../components/QRCodeModal'; +import ConsentScreen from '../components/ConsentScreen'; const PAGE_LIMIT = 20; @@ -25,6 +26,7 @@ export default function PatientDashboard() { const { t } = useTranslation(); const { publicKey, connect } = useAuth(); const { fetchRecords, loading } = useVaccination(); + const { consented, checkConsent, giveConsent, loading: consentLoading } = useConsent(); const [records, setRecords] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); @@ -63,6 +65,19 @@ export default function PatientDashboard() { ); } + // Show consent screen for first-time patients (consented === false means checked and not yet consented) + if (consented === false) { + return ( +
+ +
+ ); + } + return (