diff --git a/api/_lib/cors.js b/api/_lib/cors.js index 9b03bb5..00ed4e9 100644 --- a/api/_lib/cors.js +++ b/api/_lib/cors.js @@ -1,3 +1,5 @@ +import { isBlockedWrite, READ_ONLY_BODY, READ_ONLY_STATUS } from '../../shared/readOnly.js'; + const allowedOrigins = (process.env.ALLOWED_ORIGINS || 'http://localhost:5173,http://localhost:3000') .split(',') .map(origin => origin.trim()) @@ -47,11 +49,14 @@ export function setCorsHeaders(req, res) { } /** - * Higher-order function to handle CORS for a handler + * Higher-order function to handle CORS for a handler. + * Also refuses writes while the API is read-only (API_READ_ONLY=true). * @param {Function} handler - The route handler function + * @param {Object} [options] + * @param {boolean} [options.allowWhenReadOnly] - Let POSTs through in read-only mode (sign-in only) * @returns {Function} Wrapped handler with CORS support */ -export function handleCors(handler) { +export function handleCors(handler, { allowWhenReadOnly = false } = {}) { return async (req, res) => { const allowedOrigin = setCorsHeaders(req, res); @@ -64,6 +69,10 @@ export function handleCors(handler) { return res.status(204).end(); } + if (isBlockedWrite(req.method, { allowWrite: allowWhenReadOnly })) { + return res.status(READ_ONLY_STATUS).json(READ_ONLY_BODY); + } + return handler(req, res); }; } diff --git a/api/_lib/firebase-auth.js b/api/_lib/firebase-auth.js index e7cb4e8..7222ccb 100644 --- a/api/_lib/firebase-auth.js +++ b/api/_lib/firebase-auth.js @@ -1,4 +1,5 @@ import { createVerify } from 'node:crypto'; +import { isApiReadOnly } from '../../shared/readOnly.js'; const FIREBASE_PROJECT_ID = process.env.FIREBASE_PROJECT_ID || @@ -184,7 +185,10 @@ async function linkExistingUser(existingUser, firebaseUser, query) { return existingUser; } -export async function getOrCreateFirebaseUser(firebaseUser, query) { +// In API read-only mode this only looks up an already-linked user: it never +// syncs the email, links an existing account or creates one, so nothing in the +// database changes while it is copied. +export async function getOrCreateFirebaseUser(firebaseUser, query, { readOnly = isApiReadOnly() } = {}) { if (!firebaseUser?.uid || typeof query !== 'function') { return null; } @@ -204,6 +208,7 @@ export async function getOrCreateFirebaseUser(firebaseUser, query) { if (result.rows.length > 0) { const localUser = result.rows[0]; + if (readOnly) return localUser; // If the Firebase user has changed their verified email, sync it to the local record. // Uses IS DISTINCT FROM so the UPDATE is a no-op when another request already synced. if (firebaseUser.emailVerified && firebaseUser.email && localUser.email !== firebaseUser.email) { @@ -226,6 +231,8 @@ export async function getOrCreateFirebaseUser(firebaseUser, query) { return localUser; } + if (readOnly) return null; + if (firebaseUser.email && !firebaseUser.emailVerified) { return null; } diff --git a/api/login.js b/api/login.js index 74e3b5e..4dde75e 100644 --- a/api/login.js +++ b/api/login.js @@ -34,4 +34,5 @@ async function handler(req, res) { return res.status(status).json(body); } -export default handleCors(handler); +// Sign-in stays open in read-only mode so people can still read their data. +export default handleCors(handler, { allowWhenReadOnly: true }); diff --git a/app/src/App.jsx b/app/src/App.jsx index b9d6fc9..adeb501 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -16,6 +16,7 @@ import { DataProvider, useData } from './context/DataContext'; import { useTheme } from './context/ThemeContext'; import SyncStatusBadge from './components/SyncStatusBadge'; import SyncDetailsPanel from './components/SyncDetailsPanel'; +import MoveNotice from './components/MoveNotice'; const NavBar = () => { const { user } = useAuth(); @@ -185,6 +186,7 @@ function AppContent() { Skip to main content +
{ }, []); const handleSave = async () => { - if (!user || result === null) return; + if (!user || result === null || isAppReadOnly) return; const key = inputsKey; try { const headers = await getAuthHeaders('application/json'); @@ -755,7 +756,7 @@ const Calculator = () => { {result !== null && ( user ? (
- +

+ )} + {exportError && ( +

{exportError}

+ )} + +
+ ); +} diff --git a/app/src/components/RecoveryModal.jsx b/app/src/components/RecoveryModal.jsx index cfc24ab..cd6d50f 100644 --- a/app/src/components/RecoveryModal.jsx +++ b/app/src/components/RecoveryModal.jsx @@ -11,7 +11,7 @@ import { Link } from 'react-router-dom'; * isAuthenticated {bool} whether a user is signed in * assigning {bool} assignment in progress * onAssign {Function} copy recovery records into active account scope - * onDiscard {Function} permanently remove recovery records + * onDiscard {Function} permanently remove recovery records; omit to hide Discard * onLater {Function} dismiss modal without any action */ export default function RecoveryModal({ calcs, yields, isAuthenticated, assigning, onAssign, onDiscard, onLater }) { @@ -60,14 +60,16 @@ export default function RecoveryModal({ calcs, yields, isAuthenticated, assignin > Review later - + {onDiscard && ( + + )} ) : ( @@ -90,13 +92,15 @@ export default function RecoveryModal({ calcs, yields, isAuthenticated, assignin > Later - + {onDiscard && ( + + )} )} diff --git a/app/src/components/SignOutGuardModal.jsx b/app/src/components/SignOutGuardModal.jsx index c6a2140..dfd8106 100644 --- a/app/src/components/SignOutGuardModal.jsx +++ b/app/src/components/SignOutGuardModal.jsx @@ -76,13 +76,17 @@ export default function SignOutGuardModal({ calcs, yields, onKeep, onDiscard, on > {processing ? 'Working…' : 'Keep locally — save for next sign-in'} - + {/* No discard while the app is read-only for the move: those records + can only be kept or saved to a file (issue #130). */} + {onDiscard && ( + + )}