Skip to content
Merged
13 changes: 11 additions & 2 deletions api/_lib/cors.js
Original file line number Diff line number Diff line change
@@ -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())
Expand Down Expand Up @@ -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);

Expand All @@ -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);
};
}
9 changes: 8 additions & 1 deletion api/_lib/firebase-auth.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createVerify } from 'node:crypto';
import { isApiReadOnly } from '../../shared/readOnly.js';

const FIREBASE_PROJECT_ID =
process.env.FIREBASE_PROJECT_ID ||
Expand Down Expand Up @@ -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;
}
Expand All @@ -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) {
Expand All @@ -226,6 +231,8 @@ export async function getOrCreateFirebaseUser(firebaseUser, query) {
return localUser;
}

if (readOnly) return null;

if (firebaseUser.email && !firebaseUser.emailVerified) {
return null;
}
Expand Down
3 changes: 2 additions & 1 deletion api/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
2 changes: 2 additions & 0 deletions app/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -185,6 +186,7 @@ function AppContent() {
Skip to main content
</a>
<NavBar />
<MoveNotice />
<main id="main" tabIndex={-1} className="py-6 sm:py-8 px-4 focus:outline-none">
<Analytics />
<Suspense fallback={
Expand Down
5 changes: 3 additions & 2 deletions app/src/components/Calculator.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ACRONYMS, FISH_DATA_V3, PROFILES_DATA } from '../data/fish_data_v3';
import { Calculator as CalcIcon, Save, HelpCircle, Download, ChevronDown } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { apiUrl } from '../config/api';
import { isAppReadOnly } from '../config/move';
import { calculate } from '../lib/calcEngine';
import { parseAmount } from '../lib/numberInput';
import { withConversionStates, hasUsableConversions, parseYieldPercent } from '../lib/fishDataShape';
Expand Down Expand Up @@ -428,7 +429,7 @@ const Calculator = () => {
}, []);

const handleSave = async () => {
if (!user || result === null) return;
if (!user || result === null || isAppReadOnly) return;
const key = inputsKey;
try {
const headers = await getAuthHeaders('application/json');
Expand Down Expand Up @@ -755,7 +756,7 @@ const Calculator = () => {
{result !== null && (
user ? (
<section className="card flex flex-wrap items-center gap-x-2 gap-y-2 p-5" aria-label="Keep this result">
<button type="button" onClick={handleSave} className="btn-secondary">
<button type="button" onClick={handleSave} disabled={isAppReadOnly} className="btn-secondary disabled:cursor-not-allowed disabled:opacity-50">
<Save size={18} aria-hidden="true" /> Save calculation
</button>
<button type="button" onClick={handleExport} className="btn-ghost">
Expand Down
6 changes: 4 additions & 2 deletions app/src/components/ContributorProfile.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { User, Building2, FileText, Save, AlertCircle, CheckCircle } from 'lucid
import { useAuth } from '../context/AuthContext';
import { Link, useNavigate } from 'react-router-dom';
import { apiUrl } from '../config/api';
import { isAppReadOnly } from '../config/move';
import { DEFAULT_PROFILE_FORM, profileToFormData, buildProfilePayload } from '../lib/contributorProfile';

const ContributorProfile = () => {
Expand Down Expand Up @@ -62,6 +63,7 @@ const ContributorProfile = () => {
}, [user, getAuthHeaders]);

const handleSubmit = async (e) => {
if (isAppReadOnly) { e.preventDefault(); return; }
e.preventDefault();
if (!loaded) return;

Expand Down Expand Up @@ -130,7 +132,7 @@ const ContributorProfile = () => {
)}

<form onSubmit={handleSubmit} className="card p-8 space-y-5">
<fieldset disabled={!loaded} className="space-y-5 border-0 p-0 m-0 min-w-0">
<fieldset disabled={!loaded || isAppReadOnly} className="space-y-5 border-0 p-0 m-0 min-w-0">
<div>
<label className="form-label flex items-center gap-2">
<User size={14} />
Expand Down Expand Up @@ -199,7 +201,7 @@ const ContributorProfile = () => {
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={!loaded}
disabled={!loaded || isAppReadOnly}
className="flex-1 btn-primary flex items-center justify-center gap-2"
>
<Save size={16} />
Expand Down
22 changes: 14 additions & 8 deletions app/src/components/DataManagement.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { yieldsToCSV, downloadText } from '../lib/dataExport';

const DataManagement = () => {
const { user, getAuthHeaders } = useAuth();
const { customYields, savedCalcs, dataLoaded, addYield, updateYield, removeYield, updateYieldLocalOnly, requestPublish, unpublishCalc } = useData();
const { customYields, savedCalcs, dataLoaded, addYield, updateYield, removeYield, updateYieldLocalOnly, requestPublish, unpublishCalc, readOnly } = useData();
const [status, setStatus] = useState(null);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState(null);
Expand Down Expand Up @@ -156,7 +156,8 @@ const DataManagement = () => {
</button>
<button
onClick={() => setShowForm(!showForm)}
className="flex items-center gap-2 bg-brand-teal hover:bg-brand-teal-light text-white px-4 py-2 rounded transition text-sm font-medium"
disabled={readOnly}
className="disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 bg-brand-teal hover:bg-brand-teal-light text-white px-4 py-2 rounded transition text-sm font-medium"
>
<Plus size={16} />
Add Entry
Expand All @@ -180,7 +181,7 @@ const DataManagement = () => {
)}

{/* Add/Edit Form */}
{showForm && (
{showForm && !readOnly && (
<div className="card p-6 mb-8">
<h2 className="text-lg font-semibold text-accent mb-4">
{editingId ? 'Edit Entry' : 'Add New Entry'}
Expand Down Expand Up @@ -302,7 +303,8 @@ const DataManagement = () => {
calc.is_private === false ? (
<button
onClick={() => unpublishCalc(calc)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded border border-line text-text-secondary hover:text-text-primary hover:bg-surface transition"
disabled={readOnly}
className="disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded border border-line text-text-secondary hover:text-text-primary hover:bg-surface transition"
title="Make private"
>
<EyeOff size={13} />
Expand All @@ -311,7 +313,8 @@ const DataManagement = () => {
) : (
<button
onClick={() => requestPublish(calc)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded border border-brand-teal/40 text-accent hover:bg-brand-teal/10 transition"
disabled={readOnly}
className="disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded border border-brand-teal/40 text-accent hover:bg-brand-teal/10 transition"
title="Publish to community feed"
>
<Globe size={13} />
Expand Down Expand Up @@ -364,22 +367,25 @@ const DataManagement = () => {
{item.serverId && (
<button
onClick={() => handleToggleShare(item)}
className={`p-2 rounded transition ${item.is_shared ? 'text-accent hover:text-text-secondary' : 'text-text-secondary hover:text-accent'}`}
disabled={readOnly}
className={`disabled:opacity-50 disabled:cursor-not-allowed p-2 rounded transition ${item.is_shared ? 'text-accent hover:text-text-secondary' : 'text-text-secondary hover:text-accent'}`}
title={item.is_shared ? 'Remove from community' : 'Share with community'}
>
{item.is_shared ? <EyeOff size={16} /> : <Share2 size={16} />}
</button>
)}
<button
onClick={() => handleEdit(item)}
className="p-2 rounded text-text-secondary hover:text-accent transition"
disabled={readOnly}
className="disabled:opacity-50 disabled:cursor-not-allowed p-2 rounded text-text-secondary hover:text-accent transition"
title="Edit"
>
<Edit2 size={16} />
</button>
<button
onClick={() => handleDelete(item.id)}
className="p-2 rounded text-text-secondary hover:text-danger transition"
disabled={readOnly}
className="disabled:opacity-50 disabled:cursor-not-allowed p-2 rounded text-text-secondary hover:text-danger transition"
title="Delete"
>
<Trash2 size={16} />
Expand Down
83 changes: 83 additions & 0 deletions app/src/components/MoveNotice.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useData } from '../context/DataContext';
import { isMoveNoticeOn, NEW_APP_URL } from '../config/move';

function plural(count, one, many) {
return `${count} ${count === 1 ? one : many}`;
}

// Banner for the move to Firebase (issue #130). Shown only while
// VITE_MOVE_STAGE is "notice" or "read-only".
export default function MoveNotice() {
const { user } = useAuth();
const { readOnly, accountUnsentCount, unsentCount, saveUnsentChanges, savedCalcs, customYields, dataLoaded } = useData();

const [exportError, setExportError] = useState(null);

if (!isMoveNoticeOn) return null;

const handleSave = async () => {
setExportError(null);
try {
await saveUnsentChanges();
} catch (err) {
console.warn('Saving unsent changes failed:', err);
setExportError('Your unsent changes couldn’t be saved to a file. Please try again.');
}
};

const guestRecords = user ? 0 : savedCalcs.length + customYields.length;
const newAddress = NEW_APP_URL ? (
<a href={NEW_APP_URL} className="font-semibold underline underline-offset-2">
{NEW_APP_URL.replace(/^https?:\/\//, '')}
</a>
) : 'a new address';

return (
<section
aria-labelledby="move-notice-title"
className="border-b border-line bg-surface-raised px-4 py-3 text-sm text-text-primary"
>
<div className="mx-auto max-w-5xl space-y-1.5">
<h2 id="move-notice-title" className="font-semibold">
Local Catch is moving to {newAddress}.
</h2>
{readOnly && (
<p>
This version is read-only while we move. You can still use the calculator and look at your data,
but new changes can&apos;t be saved here.
</p>
)}
{dataLoaded && guestRecords > 0 && (
<p>
You have {plural(guestRecords, 'item', 'items')} saved on this device without an account.{' '}
<Link to="/login" className="font-semibold underline underline-offset-2">Sign in</Link>{' '}
so {guestRecords === 1 ? 'it comes' : 'they come'} with you.
</p>
)}
{user && accountUnsentCount > 0 && (
<p>
{plural(accountUnsentCount, 'change hasn’t', 'changes haven’t')} synced yet. Keep this page open and
online until it finishes.
</p>
)}
{readOnly && unsentCount > 0 && (
<p className="flex flex-wrap items-center gap-2">
<span>
{plural(unsentCount, 'change', 'changes')} on this device {unsentCount === 1 ? 'hasn’t' : 'haven’t'} reached
the server. Save {unsentCount === 1 ? 'it' : 'them'} to a file you can import into the new app.
</span>
<button type="button" onClick={handleSave} className="btn-secondary">
Save my unsent changes
</button>
</p>
)}
{exportError && (
<p role="alert" className="font-semibold text-danger">{exportError}</p>
)}
</div>
</section>
);
}
36 changes: 20 additions & 16 deletions app/src/components/RecoveryModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -60,14 +60,16 @@ export default function RecoveryModal({ calcs, yields, isAuthenticated, assignin
>
Review later
</button>
<button
onClick={onDiscard}
disabled={assigning}
className="px-4 py-2 text-sm font-medium text-danger hover:underline border border-line rounded transition flex items-center gap-1.5 disabled:opacity-50"
>
<Trash2 size={14} />
Discard
</button>
{onDiscard && (
<button
onClick={onDiscard}
disabled={assigning}
className="px-4 py-2 text-sm font-medium text-danger hover:underline border border-line rounded transition flex items-center gap-1.5 disabled:opacity-50"
>
<Trash2 size={14} />
Discard
</button>
)}
</div>
</div>
) : (
Expand All @@ -90,13 +92,15 @@ export default function RecoveryModal({ calcs, yields, isAuthenticated, assignin
>
Later
</button>
<button
onClick={onDiscard}
className="px-4 py-2 text-sm font-medium text-danger hover:underline border border-line rounded transition flex items-center gap-1.5"
>
<Trash2 size={14} />
Discard
</button>
{onDiscard && (
<button
onClick={onDiscard}
className="px-4 py-2 text-sm font-medium text-danger hover:underline border border-line rounded transition flex items-center gap-1.5"
>
<Trash2 size={14} />
Discard
</button>
)}
</div>
</div>
)}
Expand Down
18 changes: 11 additions & 7 deletions app/src/components/SignOutGuardModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,17 @@ export default function SignOutGuardModal({ calcs, yields, onKeep, onDiscard, on
>
{processing ? 'Working…' : 'Keep locally — save for next sign-in'}
</button>
<button
onClick={handleDiscard}
disabled={processing}
className="w-full text-sm font-medium px-4 py-2.5 rounded-lg border border-border bg-surface text-text-secondary hover:bg-brand-terracotta/10 hover:text-link hover:border-brand-terracotta/30 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
Discard unsaved data and sign out
</button>
{/* 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 && (
<button
onClick={handleDiscard}
disabled={processing}
className="w-full text-sm font-medium px-4 py-2.5 rounded-lg border border-border bg-surface text-text-secondary hover:bg-brand-terracotta/10 hover:text-link hover:border-brand-terracotta/30 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
Discard unsaved data and sign out
</button>
)}
<button
onClick={onCancel}
disabled={processing}
Expand Down
Loading
Loading