From b96e7fe7c146c5a4859bcf797505f285e81b2184 Mon Sep 17 00:00:00 2001
From: Enes Yilmaz
Date: Thu, 24 Sep 2026 01:30:38 -0400
Subject: [PATCH 01/23] Gate the dashboard emulator wiring on a build-time
VITE_USE_EMULATOR flag
The flag is inlined by Vite, so a production-shaped bundle can be built
against the emulators and dev can opt out. Emulator ports come from
VITE_AUTH_EMULATOR_PORT and VITE_FIRESTORE_EMULATOR_PORT.
---
dashboard/src/auth/SignIn.tsx | 4 ++--
dashboard/src/env.d.ts | 12 ++++++++++++
dashboard/src/firebase.ts | 22 +++++++++++++++-------
3 files changed, 29 insertions(+), 9 deletions(-)
create mode 100644 dashboard/src/env.d.ts
diff --git a/dashboard/src/auth/SignIn.tsx b/dashboard/src/auth/SignIn.tsx
index 2c3b3f9..d161943 100644
--- a/dashboard/src/auth/SignIn.tsx
+++ b/dashboard/src/auth/SignIn.tsx
@@ -6,7 +6,7 @@ import {
signInWithPopup,
} from 'firebase/auth';
import { FirebaseError } from 'firebase/app';
-import { auth, googleProvider } from '../firebase';
+import { auth, googleProvider, USE_EMULATOR } from '../firebase';
import Button from '../components/Button';
import { RadarIcon } from '../components/EmptyState';
import GoogleButton from './GoogleButton';
@@ -317,7 +317,7 @@ export default function SignIn() {
Terms and Privacy Policy.
- {import.meta.env.DEV ? (
+ {USE_EMULATOR ? (
+
+interface ImportMetaEnv {
+ /** 'true' or 'false'. Unset means: emulator in dev, production in a build. */
+ readonly VITE_USE_EMULATOR?: string;
+ readonly VITE_AUTH_EMULATOR_PORT?: string;
+ readonly VITE_FIRESTORE_EMULATOR_PORT?: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/dashboard/src/firebase.ts b/dashboard/src/firebase.ts
index 79029b7..a883757 100644
--- a/dashboard/src/firebase.ts
+++ b/dashboard/src/firebase.ts
@@ -2,9 +2,17 @@ import { initializeApp } from 'firebase/app';
import { connectAuthEmulator, getAuth, GoogleAuthProvider } from 'firebase/auth';
import { connectFirestoreEmulator, getFirestore } from 'firebase/firestore';
-// In dev the app runs entirely against the local emulator suite. The 'demo-'
-// project-id prefix is reserved by Firebase for emulator-only projects, so the
-// SDK can never reach production from a dev build.
+// Vite inlines VITE_* values at build time, so this is a literal in the
+// bundle. `VITE_USE_EMULATOR=true npm run build:dashboard` gives a prod-shaped
+// bundle that talks to the emulators, and dev can opt out with =false.
+const flag = import.meta.env.VITE_USE_EMULATOR;
+export const USE_EMULATOR = flag === 'true' || (flag !== 'false' && import.meta.env.DEV);
+
+const AUTH_PORT = Number(import.meta.env.VITE_AUTH_EMULATOR_PORT || 9099);
+const FIRESTORE_PORT = Number(import.meta.env.VITE_FIRESTORE_EMULATOR_PORT || 8080);
+
+// The 'demo-' project-id prefix is reserved by Firebase for emulator-only
+// projects, so this config can never reach production.
const devConfig = {
projectId: 'demo-proscan',
apiKey: 'demo-key',
@@ -22,7 +30,7 @@ const prodConfig = {
messagingSenderId: '886322190589',
};
-export const app = initializeApp(import.meta.env.DEV ? devConfig : prodConfig);
+export const app = initializeApp(USE_EMULATOR ? devConfig : prodConfig);
export const auth = getAuth(app);
// Single shared Google provider. `select_account` forces the chooser every
@@ -33,7 +41,7 @@ export const googleProvider = new GoogleAuthProvider();
googleProvider.setCustomParameters({ prompt: 'select_account' });
export const db = getFirestore(app);
-if (import.meta.env.DEV) {
- connectAuthEmulator(auth, 'http://127.0.0.1:9099', { disableWarnings: true });
- connectFirestoreEmulator(db, '127.0.0.1', 8080);
+if (USE_EMULATOR) {
+ connectAuthEmulator(auth, `http://127.0.0.1:${AUTH_PORT}`, { disableWarnings: true });
+ connectFirestoreEmulator(db, '127.0.0.1', FIRESTORE_PORT);
}
From d89d11dcb1055d4cbbaa9007ce509ee3759ce816 Mon Sep 17 00:00:00 2001
From: Enes Yilmaz
Date: Thu, 24 Sep 2026 01:30:38 -0400
Subject: [PATCH 02/23] Make emulator ports configurable through EMU_*
variables
scripts/emulators.mjs writes a temporary firebase config with the
requested ports and runs start or exec against demo-proscan. The QA
scripts read the emulator hosts from the environment instead of
hardcoding 8080 and 9099. Adds npm run test:rules.
---
.gitignore | 3 ++
package.json | 7 ++--
scripts/emulators.mjs | 85 +++++++++++++++++++++++++++++++++++++++
scripts/qa-e2e.mjs | 5 ++-
scripts/qa-sync-rules.mjs | 11 +++--
5 files changed, 102 insertions(+), 9 deletions(-)
create mode 100644 scripts/emulators.mjs
diff --git a/.gitignore b/.gitignore
index d1851dd..e3dddfb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,9 @@ dist/
# Firebase cache
.firebase/
+# Temp config written by scripts/emulators.mjs
+.firebase.emulators.json
+
# Firebase emulator persisted data (npm run emulators:persist)
.emulator-data/
diff --git a/package.json b/package.json
index 7c9ac19..76fdd72 100644
--- a/package.json
+++ b/package.json
@@ -13,9 +13,10 @@
"typecheck": "tsc -p dashboard/tsconfig.json --noEmit",
"preview": "vite preview",
"deploy": "npm run build && firebase deploy --only hosting",
- "emulators": "firebase emulators:start --project demo-proscan",
- "emulators:persist": "firebase emulators:start --project demo-proscan --import=./.emulator-data --export-on-exit=./.emulator-data",
- "seed": "node scripts/seed-emulators.mjs"
+ "emulators": "node scripts/emulators.mjs start",
+ "emulators:persist": "node scripts/emulators.mjs start --import=./.emulator-data --export-on-exit=./.emulator-data",
+ "seed": "node scripts/seed-emulators.mjs",
+ "test:rules": "node scripts/emulators.mjs exec \"node scripts/qa-sync-rules.mjs\""
},
"dependencies": {
"@fontsource-variable/archivo": "^5.2.8",
diff --git a/scripts/emulators.mjs b/scripts/emulators.mjs
new file mode 100644
index 0000000..801bf4a
--- /dev/null
+++ b/scripts/emulators.mjs
@@ -0,0 +1,85 @@
+// Runs the Firebase emulators on ports taken from the environment, so two
+// checkouts (or a stuck Java process on 8080) don't collide.
+//
+// node scripts/emulators.mjs start [extra firebase args]
+// node scripts/emulators.mjs exec ""
+//
+// Ports: EMU_AUTH_PORT, EMU_FIRESTORE_PORT, EMU_HOSTING_PORT, EMU_UI_PORT,
+// EMU_HUB_PORT, EMU_LOGGING_PORT. Unset ones keep the firebase.json value.
+// `exec` also hands the ports to the child as FIREBASE_AUTH_EMULATOR_HOST /
+// FIRESTORE_EMULATOR_HOST (set by firebase itself) and VITE_*_EMULATOR_PORT.
+
+import { spawn } from 'node:child_process';
+import { readFileSync, writeFileSync, rmSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+const root = resolve(import.meta.dirname, '..');
+const base = JSON.parse(readFileSync(resolve(root, 'firebase.json'), 'utf8'));
+const emu = base.emulators;
+
+const pick = (name, fallback) => {
+ const v = process.env[name];
+ if (!v) return fallback;
+ const n = Number(v);
+ if (!Number.isInteger(n) || n <= 0) throw new Error(`${name}=${v} is not a port`);
+ return n;
+};
+
+const ports = {
+ auth: pick('EMU_AUTH_PORT', emu.auth.port),
+ firestore: pick('EMU_FIRESTORE_PORT', emu.firestore.port),
+ hosting: pick('EMU_HOSTING_PORT', emu.hosting.port),
+ ui: pick('EMU_UI_PORT', emu.ui.port),
+ hub: pick('EMU_HUB_PORT', emu.hub?.port ?? 4400),
+ logging: pick('EMU_LOGGING_PORT', emu.logging?.port ?? 4500),
+};
+
+const config = {
+ ...base,
+ emulators: {
+ ...emu,
+ auth: { ...emu.auth, port: ports.auth },
+ firestore: { ...emu.firestore, port: ports.firestore },
+ hosting: { ...emu.hosting, port: ports.hosting },
+ ui: { ...emu.ui, port: ports.ui },
+ hub: { port: ports.hub },
+ logging: { port: ports.logging },
+ },
+};
+
+// Must sit next to firebase.json so the rules and hosting paths resolve.
+const configPath = resolve(root, '.firebase.emulators.json');
+writeFileSync(configPath, JSON.stringify(config, null, 2));
+
+const [mode, ...rest] = process.argv.slice(2);
+let args;
+if (mode === 'start') {
+ args = ['emulators:start', ...rest];
+} else if (mode === 'exec' && rest.length === 1) {
+ args = ['emulators:exec', '--only', 'auth,firestore', JSON.stringify(rest[0])];
+} else {
+ console.error('usage: emulators.mjs start [args] | exec ""');
+ process.exit(2);
+}
+args.push('--project', 'demo-proscan', '--config', '.firebase.emulators.json');
+
+console.log(`[emulators] auth ${ports.auth}, firestore ${ports.firestore}, ui ${ports.ui}`);
+const child = spawn('firebase', args, {
+ cwd: root,
+ stdio: 'inherit',
+ shell: true,
+ env: {
+ ...process.env,
+ VITE_AUTH_EMULATOR_PORT: String(ports.auth),
+ VITE_FIRESTORE_EMULATOR_PORT: String(ports.firestore),
+ },
+});
+
+const cleanup = () => rmSync(configPath, { force: true });
+child.on('exit', (code, signal) => {
+ cleanup();
+ process.exit(signal ? 1 : (code ?? 1));
+});
+for (const sig of ['SIGINT', 'SIGTERM']) {
+ process.on(sig, () => child.kill(sig));
+}
diff --git a/scripts/qa-e2e.mjs b/scripts/qa-e2e.mjs
index 7e3ee8a..7f00273 100644
--- a/scripts/qa-e2e.mjs
+++ b/scripts/qa-e2e.mjs
@@ -11,10 +11,11 @@ import { chromium } from 'playwright';
import { mkdirSync, statSync } from 'node:fs';
import { resolve } from 'node:path';
-const BASE = 'http://localhost:5173/dashboard/';
+const BASE = process.env.QA_BASE_URL || 'http://localhost:5173/dashboard/';
const OUT = resolve('.screenshots/qa');
const WID = '9f8aKq2WLxYpB3vN7cE5dRm1tUo2';
-const FIRESTORE_DOCS = `http://127.0.0.1:8080/v1/projects/demo-proscan/databases/(default)/documents/workspaces/${WID}`;
+const FS_HOST = process.env.FIRESTORE_EMULATOR_HOST || '127.0.0.1:8080';
+const FIRESTORE_DOCS = `http://${FS_HOST}/v1/projects/demo-proscan/databases/(default)/documents/workspaces/${WID}`;
mkdirSync(OUT, { recursive: true });
diff --git a/scripts/qa-sync-rules.mjs b/scripts/qa-sync-rules.mjs
index c8d9ba7..1eae650 100644
--- a/scripts/qa-sync-rules.mjs
+++ b/scripts/qa-sync-rules.mjs
@@ -5,8 +5,9 @@
// Uses the firebase WEB SDK (NOT firebase-admin) so security rules are
// ENFORCED on every call — admin bypasses rules and would prove nothing.
//
-// Connects ONLY to the local emulator suite (auth 9099, firestore 8080).
-// Run: node scripts/qa-sync-rules.mjs (emulator must be up)
+// Connects ONLY to the local emulator suite. Hosts come from the variables
+// `firebase emulators:exec` sets, defaulting to auth 9099 / firestore 8080.
+// Run: npm run test:rules
import { initializeApp, deleteApp } from 'firebase/app';
import {
@@ -29,8 +30,10 @@ import {
const app = initializeApp({ projectId: 'demo-proscan', apiKey: 'demo-key' });
const auth = getAuth(app);
const db = getFirestore(app);
-connectAuthEmulator(auth, 'http://127.0.0.1:9099', { disableWarnings: true });
-connectFirestoreEmulator(db, '127.0.0.1', 8080);
+const AUTH_HOST = process.env.FIREBASE_AUTH_EMULATOR_HOST || '127.0.0.1:9099';
+const [FS_HOST, FS_PORT] = (process.env.FIRESTORE_EMULATOR_HOST || '127.0.0.1:8080').split(':');
+connectAuthEmulator(auth, `http://${AUTH_HOST}`, { disableWarnings: true });
+connectFirestoreEmulator(db, FS_HOST, Number(FS_PORT));
const results = [];
const pass = (name, detail = '') => { results.push({ ok: true, name, detail }); console.log(` PASS ${name}${detail ? ' — ' + detail : ''}`); };
From 4089798ea5205c7f0e28188a6d45b68464792ae9 Mon Sep 17 00:00:00 2001
From: Enes Yilmaz
Date: Thu, 24 Sep 2026 01:31:09 -0400
Subject: [PATCH 03/23] Free the emulator ports after a run on Windows
The Firestore emulator's java.exe survives firebase exiting on Windows
and holds its port, which breaks the next run.
---
scripts/emulators.mjs | 32 ++++++++++++++++++++++++++++++--
1 file changed, 30 insertions(+), 2 deletions(-)
diff --git a/scripts/emulators.mjs b/scripts/emulators.mjs
index 801bf4a..7b805a2 100644
--- a/scripts/emulators.mjs
+++ b/scripts/emulators.mjs
@@ -9,7 +9,7 @@
// `exec` also hands the ports to the child as FIREBASE_AUTH_EMULATOR_HOST /
// FIRESTORE_EMULATOR_HOST (set by firebase itself) and VITE_*_EMULATOR_PORT.
-import { spawn } from 'node:child_process';
+import { execSync, spawn } from 'node:child_process';
import { readFileSync, writeFileSync, rmSync } from 'node:fs';
import { resolve } from 'node:path';
@@ -75,7 +75,35 @@ const child = spawn('firebase', args, {
},
});
-const cleanup = () => rmSync(configPath, { force: true });
+// On Windows the Firestore emulator's java.exe outlives firebase and keeps
+// its port. Kill whatever still listens on our emulator ports.
+function freePorts() {
+ if (process.platform !== 'win32') return;
+ let out = '';
+ try {
+ out = execSync('netstat -ano -p tcp', { encoding: 'utf8' });
+ } catch {
+ return;
+ }
+ const wanted = new Set([ports.firestore, ports.auth, ports.hub, ports.logging].map(String));
+ const pids = new Set();
+ for (const line of out.split('\n')) {
+ const m = line.trim().match(/^TCP\s+127\.0\.0\.1:(\d+)\s+\S+\s+LISTENING\s+(\d+)/);
+ if (m && wanted.has(m[1])) pids.add(m[2]);
+ }
+ for (const pid of pids) {
+ try {
+ execSync(`taskkill /PID ${pid} /F`, { stdio: 'ignore' });
+ } catch {
+ /* already gone */
+ }
+ }
+}
+
+const cleanup = () => {
+ rmSync(configPath, { force: true });
+ freePorts();
+};
child.on('exit', (code, signal) => {
cleanup();
process.exit(signal ? 1 : (code ?? 1));
From a1fd171d21e9b070c8f0fd5db24539911bfa273b Mon Sep 17 00:00:00 2001
From: Enes Yilmaz
Date: Thu, 24 Sep 2026 01:32:03 -0400
Subject: [PATCH 04/23] Accept lead notes on products that have no stored stage
The drawer shows 'new' by default but never writes it, so the rule's
lead.stage check rejected the first note on every untriaged product.
Treat a missing stage as 'new' and require lead to be a map. Adds
scripts/qa-lead-rules.mjs to npm run test:rules (F-44).
---
firestore.rules | 6 ++-
package.json | 2 +-
scripts/qa-lead-rules.mjs | 94 +++++++++++++++++++++++++++++++++++++++
3 files changed, 99 insertions(+), 3 deletions(-)
create mode 100644 scripts/qa-lead-rules.mjs
diff --git a/firestore.rules b/firestore.rules
index a2beb18..a120a80 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -52,9 +52,11 @@ service cloud.firestore {
request.resource.data.latest.dayKey is string
&& (!('p' in request.resource.data.latest) || request.resource.data.latest.p is int)
&& (!('v' in request.resource.data.latest) || request.resource.data.latest.v is int)))
+ // A lead with no stored stage is 'new' (the drawer never writes it).
&& (!('lead' in request.resource.data)
- || request.resource.data.lead.stage in
- ['new', 'reviewing', 'approved', 'purchased', 'rejected', 'archived']);
+ || (request.resource.data.lead is map
+ && request.resource.data.lead.get('stage', 'new') in
+ ['new', 'reviewing', 'approved', 'purchased', 'rejected', 'archived']));
match /history/{seriesId} {
allow read: if member(wid);
diff --git a/package.json b/package.json
index 76fdd72..97845a0 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
"emulators": "node scripts/emulators.mjs start",
"emulators:persist": "node scripts/emulators.mjs start --import=./.emulator-data --export-on-exit=./.emulator-data",
"seed": "node scripts/seed-emulators.mjs",
- "test:rules": "node scripts/emulators.mjs exec \"node scripts/qa-sync-rules.mjs\""
+ "test:rules": "node scripts/emulators.mjs exec \"node scripts/qa-sync-rules.mjs && node scripts/qa-lead-rules.mjs\""
},
"dependencies": {
"@fontsource-variable/archivo": "^5.2.8",
diff --git a/scripts/qa-lead-rules.mjs b/scripts/qa-lead-rules.mjs
new file mode 100644
index 0000000..448b752
--- /dev/null
+++ b/scripts/qa-lead-rules.mjs
@@ -0,0 +1,94 @@
+// Rules test for the drawer's lead writes (F-44). Typing a note on a product
+// that was never moved off "New" must be accepted: the drawer shows stage
+// 'new' by default but never stores it. Web SDK, so rules are enforced.
+// Run: npm run test:rules
+
+import { initializeApp, deleteApp } from 'firebase/app';
+import { getAuth, connectAuthEmulator, createUserWithEmailAndPassword } from 'firebase/auth';
+import {
+ getFirestore,
+ connectFirestoreEmulator,
+ doc,
+ getDoc,
+ setDoc,
+ serverTimestamp,
+} from 'firebase/firestore';
+
+const app = initializeApp({ projectId: 'demo-proscan', apiKey: 'demo-key' }, 'lead-rules');
+const auth = getAuth(app);
+const db = getFirestore(app);
+const AUTH_HOST = process.env.FIREBASE_AUTH_EMULATOR_HOST || '127.0.0.1:9099';
+const [FS_HOST, FS_PORT] = (process.env.FIRESTORE_EMULATOR_HOST || '127.0.0.1:8080').split(':');
+connectAuthEmulator(auth, `http://${AUTH_HOST}`, { disableWarnings: true });
+connectFirestoreEmulator(db, FS_HOST, Number(FS_PORT));
+
+let failed = 0;
+async function expect(name, want, fn) {
+ let got = 'allow';
+ let detail = '';
+ try {
+ await fn();
+ } catch (e) {
+ got = e.code === 'permission-denied' ? 'deny' : 'error';
+ detail = ` (${e.code || e.message})`;
+ }
+ const ok = got === want;
+ if (!ok) failed++;
+ console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}: want ${want}, got ${got}${ok ? '' : detail}`);
+}
+
+// Same write shapes as dashboard/src/features/drawer/data.ts and lib/queries.ts.
+const leadFieldPatch = (ref, patch) => setDoc(ref, { lead: patch }, { merge: true });
+const leadStageUpdate = (ref, stage) =>
+ setDoc(ref, { lead: { stage, stageChangedAt: serverTimestamp() } }, { merge: true });
+
+try {
+ const cred = await createUserWithEmailAndPassword(auth, `qa-lead-${Date.now()}@proscan.test`, 'qa-pass-123');
+ const uid = cred.user.uid;
+ const product = (asin) => doc(db, 'workspaces', uid, 'products', asin);
+
+ // A product as sync.js writes it: no lead map at all.
+ const seed = (asin) =>
+ setDoc(product(asin), {
+ asin,
+ mk: 'US',
+ name: 'QA lead product',
+ latest: { p: 1999, dayKey: '2026-09-01' },
+ });
+
+ console.log('[lead] notes on an untriaged product');
+ await seed('B0QALEAD01');
+ await expect('F-44 notes patch with no stored stage', 'allow', () =>
+ leadFieldPatch(product('B0QALEAD01'), { notes: 'call supplier' }),
+ );
+ const saved = (await getDoc(product('B0QALEAD01'))).data();
+ const noteKept = saved?.lead?.notes === 'call supplier';
+ if (!noteKept) failed++;
+ console.log(` ${noteKept ? 'PASS' : 'FAIL'} note reads back`);
+
+ await expect('rejectedReason patch with no stored stage', 'allow', () =>
+ leadFieldPatch(product('B0QALEAD01'), { rejectedReason: 'thin margin' }),
+ );
+
+ console.log('[lead] stage moves');
+ await seed('B0QALEAD02');
+ await expect('stage move to reviewing', 'allow', () =>
+ leadStageUpdate(product('B0QALEAD02'), 'reviewing'),
+ );
+ await expect('notes after a stage move', 'allow', () =>
+ leadFieldPatch(product('B0QALEAD02'), { notes: 'second look' }),
+ );
+ await expect('unknown stage', 'deny', () => leadStageUpdate(product('B0QALEAD02'), 'bogus'));
+ await expect('lead that is not a map', 'deny', () =>
+ setDoc(product('B0QALEAD02'), { lead: 'reviewing' }, { merge: true }),
+ );
+} catch (e) {
+ console.error('[fatal]', e);
+ failed++;
+} finally {
+ console.log(failed === 0 ? '\nRESULT: PASS' : `\nRESULT: FAIL (${failed})`);
+ try {
+ await deleteApp(app);
+ } catch {}
+ process.exit(failed === 0 ? 0 : 1);
+}
From 1a161e3af1144eb00d952b4d61bfeef0d2fc6077 Mon Sep 17 00:00:00 2001
From: Enes Yilmaz
Date: Thu, 24 Sep 2026 01:33:55 -0400
Subject: [PATCH 05/23] Show real Firestore errors in the dashboard
Listener and read failures are now logged in every build, not only in
dev. Board, runs, watchlist and drawer failures show the error code and
message, with the console link when an index is missing. A failed
workspace bootstrap blocks with a retry screen instead of loading an
app that cannot read. Watchlist, nickname, lead note and export write
failures raise a toast (F-42).
---
dashboard/src/auth/AuthGate.tsx | 30 ++++++---
dashboard/src/components/ErrorState.tsx | 37 +++++++++++
dashboard/src/components/Toaster.tsx | 24 +++++++
dashboard/src/components/components.css | 45 +++++++++++++
.../src/features/drawer/HistoryDrawer.tsx | 8 +++
dashboard/src/features/drawer/LeadTriage.tsx | 11 +++-
dashboard/src/features/drawer/data.ts | 3 +-
.../src/features/export/ExportButton.tsx | 3 +-
dashboard/src/features/runs/runs.css | 10 ---
.../src/features/watchlist/NicknameCell.tsx | 4 +-
.../src/features/watchlist/WatchlistTable.tsx | 3 +-
dashboard/src/lib/errors.ts | 63 +++++++++++++++++++
dashboard/src/lib/hooks.ts | 16 +++--
dashboard/src/routes/Products.tsx | 6 +-
dashboard/src/routes/Runs.tsx | 5 +-
dashboard/src/routes/Watchlist.tsx | 6 +-
dashboard/src/shell/Layout.tsx | 2 +
17 files changed, 234 insertions(+), 42 deletions(-)
create mode 100644 dashboard/src/components/ErrorState.tsx
create mode 100644 dashboard/src/components/Toaster.tsx
create mode 100644 dashboard/src/lib/errors.ts
diff --git a/dashboard/src/auth/AuthGate.tsx b/dashboard/src/auth/AuthGate.tsx
index 25691a7..ed3be41 100644
--- a/dashboard/src/auth/AuthGate.tsx
+++ b/dashboard/src/auth/AuthGate.tsx
@@ -5,6 +5,7 @@ import { db } from '../firebase';
import { useAuthUser, WorkspaceProvider } from '../lib/hooks';
import { DEFAULT_WORKSPACE_SETTINGS } from '../lib/types';
import { RadarIcon } from '../components/EmptyState';
+import ErrorState from '../components/ErrorState';
import SignIn from './SignIn';
import './auth.css';
@@ -59,11 +60,13 @@ async function bootstrapWorkspace(user: User): Promise {
}
/** Wraps the whole app: splash while auth resolves, SignIn when signed out,
- * bootstrap-then-children when signed in. Also mounts the single shared
- * workspace listener (WorkspaceProvider). */
+ * bootstrap-then-children when signed in. A failed bootstrap blocks with a
+ * retry screen. Also mounts the single shared workspace listener. */
export default function AuthGate({ children }: { children: ReactNode }) {
const { user, loading } = useAuthUser();
const [bootstrappedUid, setBootstrappedUid] = useState(null);
+ const [bootError, setBootError] = useState(null);
+ const [attempt, setAttempt] = useState(0);
const uid = user?.uid ?? null;
@@ -73,22 +76,33 @@ export default function AuthGate({ children }: { children: ReactNode }) {
return;
}
let cancelled = false;
+ setBootError(null);
bootstrapWorkspace(user)
+ .then(() => {
+ if (!cancelled) setBootstrappedUid(user.uid);
+ })
.catch((err) => {
- // Non-fatal: the app degrades to live-query errors rather than a
- // hard wall; first write retries on next sign-in.
console.error('[proscan] workspace bootstrap failed', err);
- })
- .finally(() => {
- if (!cancelled) setBootstrappedUid(user.uid);
+ if (!cancelled) setBootError(err);
});
return () => {
cancelled = true;
};
- }, [user]);
+ }, [user, attempt]);
if (loading) return ;
if (!user || !uid) return ;
+ if (bootError) {
+ return (
+
+ setAttempt((n) => n + 1)}
+ />
+
+ );
+ }
if (bootstrappedUid !== uid) return ;
return {children};
diff --git a/dashboard/src/components/ErrorState.tsx b/dashboard/src/components/ErrorState.tsx
new file mode 100644
index 0000000..9e7d413
--- /dev/null
+++ b/dashboard/src/components/ErrorState.tsx
@@ -0,0 +1,37 @@
+import type { ReactNode } from 'react';
+import { describeError } from '../lib/errors';
+import Button from './Button';
+import './components.css';
+
+export interface ErrorStateProps {
+ title: string;
+ error: unknown;
+ /** Defaults to a full page reload. */
+ onRetry?: () => void;
+ extra?: ReactNode;
+}
+
+/** Failure view that shows the real Firestore code and message. */
+export default function ErrorState({ title, error, onRetry, extra }: ErrorStateProps) {
+ const info = describeError(error);
+ return (
+