Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7491237
Vendor the extension's cloud schema and check it stays identical
EnesYilmazcode Sep 24, 2026
19043bc
Type runs and pages from the shared schema
EnesYilmazcode Sep 24, 2026
8230352
Check every document the dashboard reads against the schema
EnesYilmazcode Sep 24, 2026
0985ddb
Build the rules tests' seed products from the shared schema
EnesYilmazcode Sep 24, 2026
7088c9e
Check every Firestore write against the shared schema, and test sync …
EnesYilmazcode Sep 24, 2026
399f3c7
Keep the audit's adversarial rules probes as a permanent test
EnesYilmazcode Sep 24, 2026
78b5025
Page the product board with cursors and always show the true count
EnesYilmazcode Sep 24, 2026
e65682f
Page the run inbox instead of capping it at 30
EnesYilmazcode Sep 24, 2026
bfb523c
Group runs by the local day they ran, as the extension files them
EnesYilmazcode Sep 24, 2026
1b8ee0f
Export the whole scope a page at a time, not just the loaded rows
EnesYilmazcode Sep 24, 2026
9aeeecc
Compare a source's latest two runs page by page
EnesYilmazcode Sep 24, 2026
b8fc830
Compute deltas and movers per source from its latest two runs
EnesYilmazcode Sep 24, 2026
93dfd37
Drop the composite indexes no query uses
EnesYilmazcode Sep 24, 2026
3612e22
Keep loading while the only answer is an empty cache
EnesYilmazcode Sep 24, 2026
35314de
Let Google sign-ins set a password for the extension
EnesYilmazcode Sep 24, 2026
50be0ce
Ask for 8 characters when creating a password
EnesYilmazcode Sep 24, 2026
95a5fc2
Set the password directly when linking says the account's own email i…
EnesYilmazcode Sep 24, 2026
f9545e7
Add an e2e run: the extension's sync seeds the emulator, the producti…
EnesYilmazcode Sep 24, 2026
8df50a4
Run the schema check, unit tests and e2e in CI against the extension'…
EnesYilmazcode Sep 24, 2026
4202d6f
Add a dev seed that writes through the extension's sync module
EnesYilmazcode Sep 24, 2026
dc6b38d
Document the shared schema, the checks that run the extension, and th…
EnesYilmazcode Sep 24, 2026
cc3f64f
Make npm run seed write through the extension, and keep the old fixtu…
EnesYilmazcode Sep 24, 2026
276ac36
Drop the UTC dayKey helper the run inbox no longer uses
EnesYilmazcode Sep 24, 2026
a1bb939
Ignore line endings when comparing the vendored schema
EnesYilmazcode Sep 24, 2026
35b2734
Keep every loaded page under one live listener, and recount as it cha…
EnesYilmazcode Sep 24, 2026
14a8f73
Keep check:schema's exit code when the extension checkout is missing
EnesYilmazcode Sep 24, 2026
892865f
Serve the favicon in the e2e, and stop its preview server when launch…
EnesYilmazcode Sep 24, 2026
632ca5e
Pass deleteField to the extension sync in the e2e seed
EnesYilmazcode Sep 24, 2026
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
27 changes: 26 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,21 @@ permissions:
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 25
env:
PROSCAN_EXT: ${{ github.workspace }}/extension
steps:
- uses: actions/checkout@v4

# The schema check, the sync rules test and the e2e run the
# extension's own schema, engine and sync module. Its main branch has
# to carry packages/schema (extension 2.3).
- uses: actions/checkout@v4
with:
repository: EnesYilmazcode/AmazonSellerScraper
ref: main
path: extension

- uses: actions/setup-node@v4
with:
node-version: 22
Expand All @@ -33,11 +44,19 @@ jobs:
key: firebase-emulators-15.8.0

- run: npm ci
- run: npm ci
working-directory: extension
- run: npm install -g firebase-tools@15.8.0

- name: Typecheck
run: npm run typecheck

- name: Schema matches the extension
run: npm run check:schema

- name: Unit tests
run: npm run test:unit

- name: Build
run: npm run build

Expand All @@ -54,3 +73,9 @@ jobs:
env:
VITE_USE_EMULATOR: 'true'
run: npm run build && node scripts/check-bundle.mjs --emulator

- name: Install Chromium for the e2e
run: npx playwright install --with-deps chromium

- name: End to end (emulator, production bundle)
run: npm run test:e2e
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ firebase-debug.*.log*

# Build output (generated by `npm run build`)
dist/
# Emulator bundle built by `npm run test:e2e`
dist-e2e/

# Firebase cache
.firebase/
Expand Down
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@ npm install # one-time
npm run dev # landing page dev server
npm run dev:dashboard # dashboard dev server, talks to the emulators
npm run emulators # auth + firestore + hosting emulators (project demo-proscan)
npm run seed # demo data into the running emulators
npm run seed # scans written by the extension's own sync module
npm run seed:fixture # the older hand-made fixture (predates the schema)
npm run build # clean dist/, then build landing + dashboard
npm test # typecheck, build, bundle check, rules tests
npm test # typecheck, schema check, unit tests, build, bundle check, rules tests
npm run test:e2e # extension sync seeds the emulator, the production bundle is checked
npm run test:hosting # rewrites and cache headers (Linux or WSL only)
npm run deploy # hosting + firestore rules + indexes to proscanbot
```
Expand All @@ -61,6 +63,31 @@ refuses a dirty tree or a branch other than `main`, so the stamp always
names a real commit. CI (`.github/workflows/ci.yml`) runs the same checks
on every pull request.

## The shared schema

`packages/schema/index.js` is the extension's cloud schema, copied byte for
byte from `packages/schema/index.js` in the extension repo. It holds the
document shapes, validators and id builders for everything the extension
writes. `index.d.ts` next to it is ours. `npm run check:schema` fails when
the two copies differ; to update, copy the extension's file over ours and
adjust `index.d.ts`.

The dashboard reads every document through a converter that runs the
schema's validator (`dashboard/src/lib/checked.ts`). A document that fails
is left out of the view and counted in a notice. `firestore.rules` checks
the same shapes on write.

Several checks run the extension's real code from its checkout: the schema
check, the sync rules test, the e2e and `npm run seed`. They look for it
in `PROSCAN_EXT`, else `../ext` or `../AmazonSellerScraper`. Set
`PROSCAN_ALLOW_NO_EXT=1` to skip them where there is no checkout. CI checks
out the extension's `main`.

`npm run test:e2e` builds the dashboard with `VITE_USE_EMULATOR=true` into
`dist-e2e/`, serves it with `vite preview` and drives it with Playwright.
`QA_CHROMIUM` points at an installed Chromium when Playwright's own is
missing. Screenshots and the exported workbook land in `.screenshots/e2e/`.

## Revamp

Adds sign-in, saving client/competitor data, and integration with the ProScan
Expand Down
232 changes: 232 additions & 0 deletions dashboard/src/auth/ExtensionPassword.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
// "Set a password for the extension" (F-50). The extension signs in with
// email and password only, so an account made with Google has nothing to
// type there and its workspace never fills. Linking an email credential to
// the same account gives it a password without making a second account.

import { useState, type FormEvent } from 'react';
import {
EmailAuthProvider,
linkWithCredential,
reauthenticateWithPopup,
updatePassword,
type User,
} from 'firebase/auth';
import { FirebaseError } from 'firebase/app';
import { googleProvider } from '../firebase';
import { useAuthUser } from '../lib/hooks';
import Button from '../components/Button';
import './extpw.css';

export const MIN_PASSWORD = 8;

export function hasPassword(user: User): boolean {
return user.providerData.some((p) => p.providerId === 'password');
}

function message(err: unknown): string {
const code = codeOf(err);
switch (code) {
case 'auth/weak-password':
return `Use at least ${MIN_PASSWORD} characters.`;
case 'auth/credential-already-in-use':
case 'auth/email-already-in-use':
return 'Another ProScan account already uses this email with a password. Sign in to the extension with that password, or reset it from the extension.';
case 'auth/popup-blocked':
return 'Your browser blocked the Google window. Allow pop-ups and try again.';
case 'auth/popup-closed-by-user':
case 'auth/cancelled-popup-request':
return 'Google sign-in was closed before it finished. Try again.';
case 'auth/network-request-failed':
return 'Network error. Check your connection and try again.';
default:
return code ? `Couldn't set the password (${code}).` : "Couldn't set the password.";
}
}

const codeOf = (err: unknown) => (err instanceof FirebaseError ? err.code : '');

async function linkOnce(user: User, password: string): Promise<void> {
try {
await linkWithCredential(user, EmailAuthProvider.credential(user.email!, password));
} catch (err) {
if (codeOf(err) === 'auth/provider-already-linked') return;
// The account's own email counts as taken where the backend checks
// every account, as the Auth emulator does. With one account per email
// the only holder is this account, and a password set on it adds the
// same email provider.
if (codeOf(err) !== 'auth/email-already-in-use') throw err;
await updatePassword(user, password);
}
}

async function link(user: User, password: string): Promise<void> {
try {
await linkOnce(user, password);
} catch (err) {
if (codeOf(err) !== 'auth/requires-recent-login') throw err;
// An old Google session: confirm it, then try again.
await reauthenticateWithPopup(user, googleProvider);
await linkOnce(user, password);
}
await user.reload();
}

const DISMISS_KEY = (uid: string) => `proscan:extension-password-later:${uid}`;

function readLater(uid: string): boolean {
try {
return window.localStorage.getItem(DISMISS_KEY(uid)) === '1';
} catch {
return false;
}
}

function writeLater(uid: string, later: boolean): void {
try {
if (later) window.localStorage.setItem(DISMISS_KEY(uid), '1');
else window.localStorage.removeItem(DISMISS_KEY(uid));
} catch {
/* storage blocked: the card just comes back next visit */
}
}

/** Shown above every view to a signed-in user who has no password yet. */
export default function ExtensionPassword() {
const { user } = useAuthUser();
const [open, setOpen] = useState(false);
const [later, setLater] = useState(() => (user ? readLater(user.uid) : false));
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);

if (!user || !user.email) return null;
if (done) {
return (
<div className="extpw extpw--done" role="status">
<b>Password set.</b> In the extension, sign in with <span className="mono">{user.email}</span> and
this password. Your scans will sync here.
<button type="button" className="extpw__link" onClick={() => setDone(false)}>
Dismiss
</button>
</div>
);
}
if (hasPassword(user)) return null;

if (later && !open) {
return (
<div className="extpw extpw--slim">
The extension needs a password for <span className="mono">{user.email}</span>.
<button
type="button"
className="extpw__link"
onClick={() => {
setLater(false);
writeLater(user.uid, false);
setOpen(true);
}}
>
Set a password for the extension
</button>
</div>
);
}

const submit = async (e: FormEvent) => {
e.preventDefault();
if (busy) return;
setError(null);
if (password.length < MIN_PASSWORD) {
setError(`Use at least ${MIN_PASSWORD} characters.`);
return;
}
if (password !== confirm) {
setError("The two passwords don't match.");
return;
}
setBusy(true);
try {
await link(user, password);
setPassword('');
setConfirm('');
setOpen(false);
setDone(true);
} catch (err) {
console.error('[proscan] linking a password failed', err);
setError(message(err));
} finally {
setBusy(false);
}
};

return (
<section className="extpw" aria-labelledby="extpw-title">
<div className="extpw__text">
<h2 id="extpw-title" className="extpw__title">
Set a password for the extension
</h2>
<p className="extpw__body">
The ProScan extension signs in with an email and password. You signed in here with
Google, so the extension has nothing to sign in with yet and no scans can reach this
dashboard. Set a password for <span className="mono">{user.email}</span>, then use it in
the extension. It stays the same account.
</p>
</div>
{open ? (
<form className="extpw__form" onSubmit={submit} noValidate>
<label className="extpw__label" htmlFor="extpw-new">
New password
</label>
<input
id="extpw-new"
className="extpw__input"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={`At least ${MIN_PASSWORD} characters`}
/>
<label className="extpw__label" htmlFor="extpw-confirm">
Type it again
</label>
<input
id="extpw-confirm"
className="extpw__input"
type="password"
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
{error ? (
<div className="extpw__error" role="alert">
{error}
</div>
) : null}
<div className="extpw__actions">
<Button type="submit" disabled={busy}>
{busy ? 'Saving…' : 'Save password'}
</Button>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={busy}>
Cancel
</Button>
</div>
</form>
) : (
<div className="extpw__actions">
<Button onClick={() => setOpen(true)}>Set a password</Button>
<Button
variant="ghost"
onClick={() => {
setLater(true);
writeLater(user.uid, true);
}}
>
Not now
</Button>
</div>
)}
</section>
);
}
11 changes: 8 additions & 3 deletions dashboard/src/auth/SignIn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { auth, googleProvider, USE_EMULATOR } from '../firebase';
import Button from '../components/Button';
import { RadarIcon } from '../components/EmptyState';
import GoogleButton from './GoogleButton';
import { MIN_PASSWORD } from './ExtensionPassword';
import ScanBoard from './ScanBoard';
import './auth.css';

Expand Down Expand Up @@ -46,7 +47,7 @@ function friendlyAuthError(err: unknown): string {
case 'auth/email-already-in-use':
return 'An account with this email already exists — sign in instead.';
case 'auth/weak-password':
return 'Password must be at least 6 characters.';
return `Password must be at least ${MIN_PASSWORD} characters.`;
case 'auth/too-many-requests':
return 'Too many attempts. Wait a minute and try again.';
case 'auth/network-request-failed':
Expand Down Expand Up @@ -141,8 +142,12 @@ export default function SignIn() {
const submit = async (e: FormEvent) => {
e.preventDefault();
if (busy || googleBusy) return;
setBusy(true);
clearMessages();
if (mode === 'create' && password.length < MIN_PASSWORD) {
setError(`Password must be at least ${MIN_PASSWORD} characters.`);
return;
}
setBusy(true);
try {
if (mode === 'signin') {
await signInWithEmailAndPassword(auth, email.trim(), password);
Expand Down Expand Up @@ -260,7 +265,7 @@ export default function SignIn() {
className="auth__input"
type={showPw ? 'text' : 'password'}
autoComplete={mode === 'signin' ? 'current-password' : 'new-password'}
placeholder={mode === 'create' ? 'At least 6 characters' : 'Enter your password'}
placeholder={mode === 'create' ? `At least ${MIN_PASSWORD} characters` : 'Enter your password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
Expand Down
Loading
Loading