Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
30 changes: 29 additions & 1 deletion backend/src/indexer/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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);
}
30 changes: 30 additions & 0 deletions backend/src/routes/consent.js
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 6 additions & 0 deletions backend/src/routes/vaccination.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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)])
Expand Down
78 changes: 78 additions & 0 deletions frontend/src/components/ConsentScreen.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={s.overlay} role="dialog" aria-modal="true" aria-labelledby="consent-title">
<div style={s.modal}>
<h2 id="consent-title" style={s.title}>💉 Data Consent</h2>
<p style={s.body}>
Before your vaccination records can be issued, you must understand what data is stored
and who can access it.
</p>
<ul style={s.list}>
<li>Your <strong>Stellar wallet address</strong> is stored on a public blockchain.</li>
<li>Vaccination records (vaccine name, date, issuer) are <strong>publicly visible</strong> to anyone who queries the contract.</li>
<li>Records are <strong>permanent and cannot be deleted</strong> — only revoked by an authorized issuer.</li>
<li>Your consent timestamp and wallet address are stored off-chain by VacciChain.</li>
</ul>
<div style={s.checkRow}>
<input
id="consent-check"
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
aria-required="true"
/>
<label htmlFor="consent-check" style={s.checkLabel}>
I understand and consent to my vaccination data being stored on the Stellar blockchain.
</label>
</div>
<div style={s.btnRow}>
<button style={s.btnDecline} onClick={onDecline} type="button">
Decline
</button>
<button
style={{ ...s.btnAccept, opacity: (!checked || loading) ? 0.5 : 1 }}
onClick={onAccept}
disabled={!checked || loading}
aria-disabled={!checked || loading}
type="button"
>
{loading ? 'Recording…' : 'I Consent'}
</button>
</div>
</div>
</div>
);
}
55 changes: 55 additions & 0 deletions frontend/src/hooks/useConsent.js
Original file line number Diff line number Diff line change
@@ -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 };
}
15 changes: 15 additions & 0 deletions frontend/src/pages/PatientDashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
Expand Down Expand Up @@ -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 (
<div style={styles.page}>
<ConsentScreen
onAccept={giveConsent}
onDecline={handleDeclineConsent}
loading={consentLoading}
/>
</div>
);
}

return (
<div style={styles.page}>
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'space-between', alignItems: 'baseline', gap: '0.5rem', marginBottom: '1.5rem' }}>
Expand Down
Loading