diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..33fe7d7 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,29 @@ +name: CARDCAP checks +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + checks: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm test + - run: npm run lint + - run: npm run build + - run: npm run test:e2e + - run: npx playwright install --with-deps chromium + - run: npm run test:browser + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: failure-traces + path: test-results/ diff --git a/.gitignore b/.gitignore index 2de7546..dfe0085 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ cardcap-live-cookie.tmp *.njsproj *.sln *.sw? + +.claude/ +test-results/ +playwright-report/ diff --git a/e2e/browser/cardcap.spec.ts b/e2e/browser/cardcap.spec.ts new file mode 100644 index 0000000..ff07091 --- /dev/null +++ b/e2e/browser/cardcap.spec.ts @@ -0,0 +1,191 @@ +import { readFileSync } from 'node:fs' +import { expect, test, type APIRequestContext, type Browser, type Page } from '@playwright/test' +import { PNG_BUFFER, PNG_HEIGHT, PNG_WIDTH } from '../fixtures' + +// Real Chromium against the built SPA + the real Worker on local Miniflare (see e2e/browser/server.ts). +// OpenAI/Resend are intercepted by the server; data is synthetic. + +const UPSTREAM_SECRET = 'upstream-internal-detail-abc123' + +let counter = 0 +const uniqueEmail = (name: string) => `${name}-${Date.now()}-${++counter}@example.test` + +async function signIn(page: Page, request: APIRequestContext, email: string) { + await page.goto('/') + await page.getByRole('button', { name: 'Use email link' }).click() + await page.getByPlaceholder('you@company.com').fill(email) + await page.getByRole('button', { name: 'Email me a sign-in link' }).click() + await expect.poll(async () => (await request.get(`/__test/login-link?email=${encodeURIComponent(email)}`)).status()).toBe(200) + const link = await (await request.get(`/__test/login-link?email=${encodeURIComponent(email)}`)).text() + await page.goto(link) + await expect(page.getByRole('button', { name: 'Add Cards' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible() +} + +async function uploadCard(page: Page, name = 'maria-card.png') { + await page.locator('input[type=file][accept="image/*"]').setInputFiles({ name, mimeType: 'image/png', buffer: PNG_BUFFER }) +} + +async function r2Keys(request: APIRequestContext): Promise { + return (await request.get('/__test/r2')).json() +} + +async function downloadText(page: Page, buttonName: string): Promise<{ text: string; filename: string }> { + const [download] = await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: buttonName, exact: true }).click()]) + return { text: readFileSync(await download.path(), 'utf8'), filename: download.suggestedFilename() } +} + +test.beforeEach(async ({ request }) => { + await request.get('/__test/openai?mode=card') +}) + +test('upload → review/edit → save → reload → export keeps exactly the corrected values', async ({ page, request }) => { + await signIn(page, request, uniqueEmail('alice')) + await uploadCard(page) + + // Draft from the card, flagged for review. + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor') + await expect(page.getByText('Needs review').first()).toBeVisible() + await expect(page.getByLabel('Company', { exact: true })).toHaveValue('Sunrise Realty Group') + + // Review/edit like a person: fix name and role, set status, and use the explicit Save Contact button first. + await page.getByLabel('Name', { exact: true }).fill('Maria Okafor-Lee') + await page.getByLabel('Role', { exact: true }).fill('Managing Broker') + await page.locator('.form-grid select').selectOption('Active') + await page.getByRole('button', { name: 'Save Contact' }).click() + await expect(page.getByText('Saved.', { exact: true })).toBeVisible() + await expect(page.getByText('Needs review').first()).toBeVisible() // saving alone does not clear the review flag + + // Reload after Save Contact: values persisted on the server, still flagged for review. + await page.reload() + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor-Lee') + await expect(page.getByLabel('Role', { exact: true })).toHaveValue('Managing Broker') + await expect(page.locator('.form-grid select')).toHaveValue('Active') + await expect(page.getByText('Needs review').first()).toBeVisible() + + // Then mark reviewed, which clears the flag. + await page.getByRole('button', { name: 'Mark Reviewed' }).click() + await expect(page.getByText('Marked reviewed.', { exact: true })).toBeVisible() + await expect(page.getByText('Needs review')).toHaveCount(0) + + // Reload: same values come back from the server, not from client memory; the real image renders. + await page.reload() + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor-Lee') + await expect(page.getByLabel('Role', { exact: true })).toHaveValue('Managing Broker') + await expect(page.locator('.form-grid select')).toHaveValue('Active') + await expect(page.getByText('Needs review')).toHaveCount(0) + const image = page.locator('img').first() + await expect(image).toBeVisible() + await expect.poll(() => image.evaluate((img: HTMLImageElement) => `${img.complete}:${img.naturalWidth}x${img.naturalHeight}`)).toBe(`true:${PNG_WIDTH}x${PNG_HEIGHT}`) + + // Export: the downloaded CSV has exactly the corrected row. + const csv = await downloadText(page, 'CSV') + expect(csv.filename).toBe('cardcap-contacts.csv') + expect(csv.text).toBe( + [ + 'name,company,role,email,phones,website,address,tags,status,next_step,notes', + 'Maria Okafor-Lee,Sunrise Realty Group,Managing Broker,maria@sunrise.example,(602) 555-0142,https://sunrise.example,"12 Main St, Phoenix, AZ","Luxury",Active,,Met at expo', + ].join('\n'), + ) + const vcf = await downloadText(page, 'vCard') + expect(vcf.text).toContain('FN:Maria Okafor-Lee\r\n') + expect(vcf.text).toContain('TITLE:Managing Broker\r\n') +}) + +test('a failed extraction shows a safe message, leaves nothing behind, and a retry works', async ({ page, request }) => { + await signIn(page, request, uniqueEmail('alice')) + const r2Before = await r2Keys(request) + + await request.get('/__test/openai?mode=error') + await uploadCard(page) + const banner = page.locator('.banner.error') + await expect(banner).toBeVisible() + await expect(banner).not.toContainText(UPSTREAM_SECRET) + await expect(banner).not.toContainText('OpenAI') + expect(await r2Keys(request)).toEqual(r2Before) + await expect(page.getByText('No contacts yet.')).toBeVisible() + + await request.get('/__test/openai?mode=card') + await uploadCard(page) + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor') + expect((await r2Keys(request)).length).toBe(r2Before.length + 1) +}) + +test('forged Cloudflare Access identity is not a login', async ({ browser }) => { + const context = await browser.newContext({ + extraHTTPHeaders: { 'Cf-Access-Authenticated-User-Email': 'alice@example.test' }, + }) + await context.addCookies([{ name: 'CF_Authorization', value: `x.${Buffer.from(JSON.stringify({ sub: 'attacker', email: 'alice@example.test' })).toString('base64url')}.y`, url: 'http://127.0.0.1:5199' }]) + const page = await context.newPage() + await page.goto('/') + await expect(page.getByRole('heading', { name: 'Sign in to CardCap' })).toBeVisible() + expect((await page.request.get('/api/contacts')).status()).toBe(401) + await context.close() +}) + +async function newUser(browser: Browser, request: APIRequestContext, name: string) { + const context = await browser.newContext() + const page = await context.newPage() + await signIn(page, request, uniqueEmail(name)) + return { context, page } +} + +test("cross-user isolation: another user's browser session cannot see, read the image of, or change a contact", async ({ browser, request }) => { + const alice = await newUser(browser, request, 'alice') + await uploadCard(alice.page) + await expect(alice.page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor') + const imageUrl = (await alice.page.locator('img').first().getAttribute('src'))! + const contacts = (await (await alice.page.request.get('/api/contacts')).json()) as { contacts: Array<{ id: string }> } + const aliceContactId = contacts.contacts[0].id + expect((await alice.page.request.get(imageUrl)).status()).toBe(200) + + const bob = await newUser(browser, request, 'bob') + await expect(bob.page.getByText('No contacts yet.')).toBeVisible() + await expect(bob.page.getByText('Maria Okafor')).toHaveCount(0) + expect((await bob.page.request.get(imageUrl)).status()).toBe(404) + expect((await bob.page.request.get(`/api/contacts/${aliceContactId}`)).status()).toBe(404) + const put = await bob.page.request.put(`/api/contacts/${aliceContactId}`, { data: { name: 'Hijacked' } }) + expect(put.status()).toBe(404) + const csv = await bob.page.request.get('/api/export.csv') + expect(await csv.text()).not.toContain('Maria') + + // Alice is untouched and still sees her image. + await alice.page.reload() + await expect(alice.page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor') + expect((await alice.page.request.get(imageUrl)).status()).toBe(200) + await alice.context.close() + await bob.context.close() +}) + +test('password: set it in the account panel, sign out, then sign in with email and password', async ({ page, request }) => { + const email = uniqueEmail('alice') + await signIn(page, request, email) + await uploadCard(page) + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor') + + await page.getByRole('button', { name: 'Set password' }).click() + await page.getByLabel('New password').fill('correct horse battery') + await page.getByLabel('Confirm password').fill('different password') + await page.getByRole('button', { name: 'Save password' }).click() + await expect(page.getByText('Passwords do not match.')).toBeVisible() + + await page.getByLabel('Confirm password').fill('correct horse battery') + await page.getByRole('button', { name: 'Save password' }).click() + await expect(page.getByText('Password saved.')).toBeVisible() + + await page.getByRole('button', { name: 'Sign out' }).click() + await expect(page.getByRole('heading', { name: 'Sign in to CardCap' })).toBeVisible() + + await page.getByPlaceholder('you@company.com').fill(email) + await page.getByLabel('Password', { exact: true }).fill('wrong password here') + await page.getByRole('button', { name: 'Sign in', exact: true }).click() + await expect(page.getByText('Invalid email or password.')).toBeVisible() + + await page.getByLabel('Password', { exact: true }).fill('correct horse battery') + await page.getByRole('button', { name: 'Sign in', exact: true }).click() + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor') + + // The session survives a reload. + await page.reload() + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor') +}) diff --git a/e2e/browser/server.ts b/e2e/browser/server.ts new file mode 100644 index 0000000..b6d2405 --- /dev/null +++ b/e2e/browser/server.ts @@ -0,0 +1,70 @@ +// Serves the built SPA (dist/client) plus the real Worker on local Miniflare for browser tests. +// /__test/* are test-only controls; they are not part of the app. +import { existsSync, readFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { dirname, extname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { startHarness } from '../harness' + +const port = Number(process.env.PORT || 5199) +const dist = resolve(process.env.CARDCAP_DIST || join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'dist', 'client')) +const types: Record = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png' } + +async function main() { + const h = await startHarness() + h.openai.mode = { + kind: 'card', + card: { + name: 'Maria Okafor', company: 'Sunrise Realty Group', role: 'Broker', emails: ['maria@sunrise.example'], + phones: ['(602) 555-0142'], website: 'sunrise.example', address: '12 Main St, Phoenix, AZ', + tags: ['Luxury'], notes: 'Met at expo', confidence: 0.62, needs_review: true, + }, + } + + createServer(async (req, res) => { + try { + const url = new URL(req.url || '/', `http://127.0.0.1:${port}`) + if (url.pathname === '/__test/login-link') { + const link = h.lastLoginLink(url.searchParams.get('email') || '') + res.writeHead(link ? 200 : 404, { 'content-type': 'text/plain' }).end(link ? new URL(link).pathname + new URL(link).search : '') + return + } + if (url.pathname === '/__test/openai') { + const kind = url.searchParams.get('mode') + if (kind === 'error') h.openai.mode = { kind: 'http-error', status: 500, body: 'upstream-internal-detail-abc123' } + else if (kind === 'garbage') h.openai.mode = { kind: 'garbage' } + else h.openai.mode = { kind: 'card', card: { name: 'Maria Okafor', company: 'Sunrise Realty Group', role: 'Broker', emails: ['maria@sunrise.example'], phones: ['(602) 555-0142'], website: 'sunrise.example', address: '12 Main St, Phoenix, AZ', tags: ['Luxury'], notes: 'Met at expo', confidence: 0.62, needs_review: true } } + res.writeHead(200).end('ok') + return + } + if (url.pathname === '/__test/r2') { + res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await h.r2Keys())) + return + } + if (url.pathname.startsWith('/api/')) { + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(chunk as Buffer) + const headers = new Headers() + for (const [k, v] of Object.entries(req.headers)) if (v && k !== 'host' && k !== 'connection') headers.set(k, Array.isArray(v) ? v.join(', ') : v) + const body = chunks.length ? Buffer.concat(chunks) : undefined + const out = (await h.mf.dispatchFetch(`https://cardcap.test${url.pathname}${url.search}`, { + method: req.method, headers, body: req.method === 'GET' || req.method === 'HEAD' ? undefined : body, redirect: 'manual', + } as never)) as unknown as Response + const outHeaders: Record = {} + out.headers.forEach((v, k) => { if (k !== 'set-cookie') outHeaders[k] = v }) + const cookies = (out.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie?.() || [] + // Local http: drop Secure so the browser accepts the session cookie. + if (cookies.length) outHeaders['set-cookie'] = cookies.map((c) => c.replace(/;\s*Secure/i, '')) + res.writeHead(out.status, outHeaders).end(Buffer.from(await out.arrayBuffer())) + return + } + let file = join(dist, url.pathname === '/' ? 'index.html' : url.pathname) + if (!file.startsWith(dist) || !existsSync(file)) file = join(dist, 'index.html') + res.writeHead(200, { 'content-type': types[extname(file)] || 'application/octet-stream' }).end(readFileSync(file)) + } catch (error) { + res.writeHead(500).end(String(error)) + } + }).listen(port, '127.0.0.1', () => console.log(`cardcap e2e server on http://127.0.0.1:${port}`)) +} + +void main() diff --git a/e2e/cardcap.e2e.test.ts b/e2e/cardcap.e2e.test.ts new file mode 100644 index 0000000..086907f --- /dev/null +++ b/e2e/cardcap.e2e.test.ts @@ -0,0 +1,368 @@ +import { afterEach, beforeEach, describe, expect, it, onTestFailed } from 'vitest' +import { emptyForm, PNG_BYTES, saveTrace, startHarness, uploadForm, type Harness, type Session } from './harness' + +// Synthetic card data. Expected values below are written by hand from what a person would expect to see, +// not derived from the app's own formatting code. +const CARD = { + name: 'Maria Okafor', + company: 'Sunrise Realty Group', + role: 'Broker', + emails: ['maria@sunrise.example'], + phones: ['(602) 555-0142', '602-555-0199'], + website: 'sunrise.example', + address: '12 Main St, Phoenix, AZ', + tags: ['Luxury', 'Buyer agent'], + notes: 'Met at expo', + confidence: 0.62, + needs_review: true, +} + +let h: Harness + +beforeEach(async () => { + h = await startHarness() + h.openai.mode = { kind: 'card', card: CARD } +}) + +afterEach(async () => { + await h.dispose() +}) + +function scenario(name: string, fn: () => Promise) { + it(name, async () => { + onTestFailed((result) => saveTrace(name, h.trace, result.errors?.[0]?.message)) + await fn() + }) +} + +async function uploadCard(session: Session, fileName = 'maria-card.png') { + const res = await session.request('/api/cards/upload', { method: 'POST', body: uploadForm(fileName) }) + const text = await res.text() + const body = (text ? JSON.parse(text) : {}) as { contact: Record & { id: string; sourceImageKey: string; sourceImageUrl: string } } + return { res, text, body } +} + +describe('upload → review/edit → save → reload → export', () => { + scenario('a scanned card is extracted, corrected by the user, persisted, and exported with the corrected values', async () => { + const alice = await h.signIn('alice@example.test') + + // Upload: draft arrives flagged for review with what the card said. + const { res, body } = await uploadCard(alice) + expect(res.status).toBe(201) + expect(body.contact).toMatchObject({ + name: 'Maria Okafor', + company: 'Sunrise Realty Group', + role: 'Broker', + email: 'maria@sunrise.example', + phones: ['(602) 555-0142', '602-555-0199'], + website: 'https://sunrise.example', + address: '12 Main St, Phoenix, AZ', + tags: ['Luxury', 'Buyer agent'], + notes: 'Met at expo', + status: 'Follow up', + nextStep: 'Review extracted card', + needsReview: true, + extractionConfidence: 0.62, + }) + const id = body.contact.id + + // The stored image is the exact uploaded file. + const image = await alice.request(body.contact.sourceImageUrl) + expect(image.status).toBe(200) + expect(image.headers.get('content-type')).toBe('image/png') + expect(new Uint8Array(await image.arrayBuffer())).toEqual(PNG_BYTES) + + // Review/edit: the user fixes the name, drops a phone, adds notes, and marks it reviewed. + const edit = await alice.request(`/api/contacts/${id}`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + contact: { + name: 'Maria Okafor-Lee', + company: 'Sunrise Realty Group', + role: 'Managing Broker', + email: 'maria@sunrise.example', + phones: ['(602) 555-0142'], + website: 'https://sunrise.example', + address: '12 Main St, Phoenix, AZ', + tags: ['Luxury', 'VIP'], + notes: 'Met at expo; wants condo list, weekly', + nextStep: 'Send listings Friday', + status: 'Active', + needsReview: false, + }, + }), + }) + expect(edit.status).toBe(200) + + // Reload: a fresh read returns exactly one contact with the saved values (and the image link survives the edit). + const list = (await (await alice.request('/api/contacts')).json()) as { contacts: Array> } + expect(list.contacts).toHaveLength(1) + expect(list.contacts[0]).toMatchObject({ + id, + name: 'Maria Okafor-Lee', + role: 'Managing Broker', + phones: ['(602) 555-0142'], + tags: ['Luxury', 'VIP'], + notes: 'Met at expo; wants condo list, weekly', + nextStep: 'Send listings Friday', + status: 'Active', + needsReview: false, + sourceImageUrl: body.contact.sourceImageUrl, + }) + const reloadedImage = await alice.request(body.contact.sourceImageUrl) + expect(reloadedImage.status).toBe(200) + + // Export: every format carries the corrected values, not the extracted draft. + const csv = await alice.request('/api/export.csv') + expect(csv.headers.get('content-disposition')).toBe('attachment; filename="cardcap-contacts.csv"') + expect(await csv.text()).toBe( + [ + 'name,company,role,email,phones,website,address,tags,status,next_step,notes', + 'Maria Okafor-Lee,Sunrise Realty Group,Managing Broker,maria@sunrise.example,(602) 555-0142,https://sunrise.example,"12 Main St, Phoenix, AZ","Luxury; VIP",Active,Send listings Friday,"Met at expo; wants condo list, weekly"', + ].join('\n'), + ) + + const vcf = await alice.request('/api/export.vcf') + expect(await vcf.text()).toBe( + [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'FN:Maria Okafor-Lee', + 'ORG:Sunrise Realty Group', + 'TITLE:Managing Broker', + 'TEL;TYPE=CELL:(602) 555-0142', + 'EMAIL:maria@sunrise.example', + 'URL:https://sunrise.example', + 'ADR;TYPE=WORK:;;12 Main St\\, Phoenix\\, AZ;;;;', + 'NOTE:Send listings Friday - Met at expo\\; wants condo list\\, weekly', + 'END:VCARD', + ].join('\r\n'), + ) + + const icontact = (await (await alice.request('/api/export.icontact.csv')).text()).split('\n') + expect(icontact[0]).toBe('Email,First Name,Last Name,Company,Job Title,Phone,Street,City,State,Zip,Notes') + expect(icontact[1].startsWith('maria@sunrise.example,Maria,Okafor-Lee,Sunrise Realty Group,Managing Broker,(602) 555-0142,')).toBe(true) + + const json = (await (await alice.request('/api/export.json')).json()) as Array> + expect(json).toHaveLength(1) + expect(json[0]).toMatchObject({ id, name: 'Maria Okafor-Lee', status: 'Active', needsReview: false }) + }) + + scenario('re-uploading the same card updates the existing contact instead of creating a second one', async () => { + const alice = await h.signIn('alice@example.test') + const first = await uploadCard(alice) + const second = await uploadCard(alice, 'maria-card-again.png') + expect(second.res.status).toBe(200) + const list = (await (await alice.request('/api/contacts')).json()) as { contacts: unknown[] } + expect(list.contacts).toHaveLength(1) + expect(second.body.contact.id).toBe(first.body.contact.id) + }) +}) + +describe('failed extraction', () => { + scenario('an extraction service error fails the upload cleanly: no contact, no orphaned image, no upstream detail leaked', async () => { + const alice = await h.signIn('alice@example.test') + h.openai.mode = { kind: 'http-error', status: 500, body: 'upstream-internal-detail-abc123' } + + const { res, text } = await uploadCard(alice) + expect(res.status).toBe(502) + expect(text).not.toContain('upstream-internal-detail-abc123') + + const list = (await (await alice.request('/api/contacts')).json()) as { contacts: unknown[] } + expect(list.contacts).toEqual([]) + expect(await h.r2Keys()).toEqual([]) + }) + + scenario('unparseable model output fails the upload cleanly with no contact and no orphaned image', async () => { + const alice = await h.signIn('alice@example.test') + h.openai.mode = { kind: 'garbage' } + + const { res } = await uploadCard(alice) + expect([422, 502]).toContain(res.status) + + const list = (await (await alice.request('/api/contacts')).json()) as { contacts: unknown[] } + expect(list.contacts).toEqual([]) + expect(await h.r2Keys()).toEqual([]) + }) + + scenario('after a failed extraction the user can retry the same card and get one correct contact', async () => { + const alice = await h.signIn('alice@example.test') + h.openai.mode = { kind: 'http-error', status: 503, body: 'overloaded' } + expect((await uploadCard(alice)).res.status).toBe(502) + + h.openai.mode = { kind: 'card', card: CARD } + const retry = await uploadCard(alice) + expect(retry.res.status).toBe(201) + + const list = (await (await alice.request('/api/contacts')).json()) as { contacts: Array<{ name: string }> } + expect(list.contacts.map((c) => c.name)).toEqual(['Maria Okafor']) + expect(await h.r2Keys()).toHaveLength(1) + }) + + scenario('non-image and missing files are rejected before anything is stored or sent to the extractor', async () => { + const alice = await h.signIn('alice@example.test') + const notImage = await alice.request('/api/cards/upload', { method: 'POST', body: uploadForm('notes.txt', 'text/plain', new TextEncoder().encode('hello')) }) + expect(notImage.status).toBe(400) + const noFile = await alice.request('/api/cards/upload', { method: 'POST', body: emptyForm() }) + expect(noFile.status).toBe(400) + expect(await h.r2Keys()).toEqual([]) + expect(h.openai.calls).toBe(0) + }) +}) + +describe('cross-user isolation', () => { + async function aliceWithCard() { + const alice = await h.signIn('alice@example.test') + const bob = await h.signIn('bob@example.test') + const { body } = await uploadCard(alice) + return { alice, bob, contact: body.contact } + } + + async function aliceContact(alice: Session, id: string) { + const res = await alice.request(`/api/contacts/${id}`) + return { status: res.status, contact: ((await res.json()) as { contact?: Record }).contact } + } + + scenario("another user does not see, fetch, or export someone else's contacts", async () => { + const { bob, contact } = await aliceWithCard() + + expect(((await (await bob.request('/api/contacts')).json()) as { contacts: unknown[] }).contacts).toEqual([]) + expect((await bob.request(`/api/contacts/${contact.id}`)).status).toBe(404) + + for (const path of ['/api/export.csv', '/api/export.vcf', '/api/export.json', '/api/export.icontact.csv', '/api/export.html']) { + const text = await (await bob.request(path)).text() + expect(text, path).not.toContain('Maria') + expect(text, path).not.toContain('maria@sunrise.example') + } + }) + + scenario("another user cannot read someone else's card image", async () => { + const { alice, bob, contact } = await aliceWithCard() + expect((await alice.request(contact.sourceImageUrl)).status).toBe(200) + const stolen = await bob.request(contact.sourceImageUrl) + expect(stolen.status).toBe(404) + expect(new Uint8Array(await stolen.arrayBuffer())).not.toEqual(PNG_BYTES) + }) + + scenario('the owner’s image response is not cacheable across accounts and cannot run as active content', async () => { + const { alice, contact } = await aliceWithCard() + const res = await alice.request(contact.sourceImageUrl) + expect(res.status).toBe(200) + expect(res.headers.get('cache-control')).toMatch(/private/) + expect(res.headers.get('cache-control')).toMatch(/no-store/) + expect(res.headers.get('x-content-type-options')).toBe('nosniff') + expect(res.headers.get('content-security-policy')).toMatch(/default-src 'none'/) + expect(res.headers.get('content-security-policy')).toMatch(/sandbox/) + }) + + scenario("another user cannot overwrite someone else's contact by PUT to its id", async () => { + const { alice, bob, contact } = await aliceWithCard() + const attack = await bob.request(`/api/contacts/${contact.id}`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Hijacked', email: 'evil@example.test', status: 'Archived' }), + }) + expect(attack.status).toBe(404) + + const after = await aliceContact(alice, contact.id) + expect(after.contact).toMatchObject({ name: 'Maria Okafor', email: 'maria@sunrise.example', status: 'Follow up' }) + expect(((await (await bob.request('/api/contacts')).json()) as { contacts: unknown[] }).contacts).toEqual([]) + }) + + scenario("another user cannot overwrite someone else's contact by POSTing a colliding id", async () => { + const { alice, bob, contact } = await aliceWithCard() + const attack = await bob.request('/api/contacts', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: contact.id, name: 'Hijacked', email: 'evil@example.test' }), + }) + expect([403, 404, 409]).toContain(attack.status) + + const after = await aliceContact(alice, contact.id) + expect(after.contact).toMatchObject({ name: 'Maria Okafor', email: 'maria@sunrise.example' }) + }) + + scenario("another user cannot delete someone else's contact or its image", async () => { + const { alice, bob, contact } = await aliceWithCard() + const attack = await bob.request(`/api/contacts/${contact.id}`, { method: 'DELETE' }) + expect(attack.status).toBe(404) + expect(await attack.json()).toEqual({ deleted: false }) + + expect((await aliceContact(alice, contact.id)).status).toBe(200) + expect((await alice.request(contact.sourceImageUrl)).status).toBe(200) + expect(await h.r2Keys()).toEqual([contact.sourceImageKey]) + }) + + scenario('uploading the same card as another user creates a separate contact and leaves the first untouched', async () => { + const { alice, bob, contact } = await aliceWithCard() + const bobs = await uploadCard(bob, 'maria-card.png') + expect(bobs.res.status).toBe(201) + expect(bobs.body.contact.id).not.toBe(contact.id) + expect(bobs.body.contact.sourceImageKey).not.toBe(contact.sourceImageKey) + + const aliceList = (await (await alice.request('/api/contacts')).json()) as { contacts: Array<{ id: string }> } + expect(aliceList.contacts.map((c) => c.id)).toEqual([contact.id]) + const bobList = (await (await bob.request('/api/contacts')).json()) as { contacts: Array<{ id: string }> } + expect(bobList.contacts.map((c) => c.id)).toEqual([bobs.body.contact.id]) + expect((await alice.request(bobs.body.contact.sourceImageUrl)).status).toBe(404) + }) +}) + +describe('unauthenticated and forged access', () => { + scenario('every protected route rejects a request with no session', async () => { + const { contact } = await (async () => { + const alice = await h.signIn('alice@example.test') + return { contact: (await uploadCard(alice)).body.contact } + })() + const anon = h.anonymous() + const routes: Array<[string, string]> = [ + ['GET', '/api/me'], + ['GET', '/api/contacts'], + ['GET', `/api/contacts/${contact.id}`], + ['GET', contact.sourceImageUrl], + ['GET', '/api/export.csv'], + ['GET', '/api/export.vcf'], + ['GET', '/api/export.json'], + ['GET', '/api/export.icontact.csv'], + ['GET', '/api/export.html'], + ['DELETE', `/api/contacts/${contact.id}`], + ] + for (const [method, path] of routes) { + expect((await anon.request(path, { method })).status, `${method} ${path}`).toBe(401) + } + expect((await anon.request('/api/cards/upload', { method: 'POST', body: uploadForm('x.png') })).status).toBe(401) + }) + + scenario('a forged Cloudflare Access identity header is not accepted as a login', async () => { + const alice = await h.signIn('alice@example.test') + await uploadCard(alice) + const forged = await h.anonymous().request('/api/contacts', { headers: { 'Cf-Access-Authenticated-User-Email': 'alice@example.test' } }) + expect(forged.status).toBe(401) + }) + + scenario('a forged Access JWT cookie is not accepted as a login', async () => { + const payload = btoa(JSON.stringify({ sub: 'attacker', email: 'alice@example.test' })).replace(/=+$/, '') + const forged = await h.anonymous().request('/api/contacts', { headers: { Cookie: `CF_Authorization=x.${payload}.y` } }) + expect(forged.status).toBe(401) + }) + + scenario("a session cookie with a tampered user id is rejected (cannot impersonate another user)", async () => { + const alice = await h.signIn('alice@example.test') + await uploadCard(alice) + const [version, , expires, signature] = decodeURIComponent(alice.cookie.split('=')[1]).split('.') + const bobId = btoa('email:bob@example.test').replace(/=+$/, '') + const tampered = `cardcap_session=${encodeURIComponent([version, bobId, expires, signature].join('.'))}` + const res = await h.anonymous().request('/api/contacts', { headers: { Cookie: tampered } }) + expect(res.status).toBe(401) + }) + + scenario('a login link works exactly once', async () => { + // signIn consumes the link; replaying the same token must fail. + const before = h.trace.length + await h.signIn('alice@example.test') + const verifyCall = h.trace.slice(before).find((t) => t.url.startsWith('/api/auth/verify'))! + const replay = await h.anonymous().request(verifyCall.url) + expect(replay.status).toBe(410) + }) +}) diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..5d496b7 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,45 @@ +import { deflateSync } from 'node:zlib' + +// A real, decodable synthetic PNG (3x2, solid color) built in code so tests can assert the browser actually renders it. +export const PNG_WIDTH = 3 +export const PNG_HEIGHT = 2 + +const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { + let c = n + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + return c >>> 0 +}) + +function crc32(buf: Buffer): number { + let c = 0xffffffff + for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8) + return (c ^ 0xffffffff) >>> 0 +} + +function chunk(type: string, data: Buffer): Buffer { + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]) + const out = Buffer.alloc(body.length + 8) + out.writeUInt32BE(data.length, 0) + body.copy(out, 4) + out.writeUInt32BE(crc32(body), body.length + 4) + return out +} + +function buildPng(): Buffer { + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(PNG_WIDTH, 0) + ihdr.writeUInt32BE(PNG_HEIGHT, 4) + ihdr[8] = 8 // bit depth + ihdr[9] = 2 // RGB + const row = Buffer.concat([Buffer.from([0]), Buffer.from(Array.from({ length: PNG_WIDTH }, () => [0x14, 0x6c, 0x5f]).flat())]) + const raw = Buffer.concat(Array.from({ length: PNG_HEIGHT }, () => row)) + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]) +} + +export const PNG_BUFFER = buildPng() +export const PNG_BYTES = new Uint8Array(PNG_BUFFER) diff --git a/e2e/harness.ts b/e2e/harness.ts new file mode 100644 index 0000000..9cd8536 --- /dev/null +++ b/e2e/harness.ts @@ -0,0 +1,166 @@ +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { dirname, join, resolve } from 'node:path' +import { build } from 'esbuild' +import { PNG_BYTES } from './fixtures' +import { FormData as MfFormData, Miniflare, Response as MfResponse } from 'miniflare' + +const here = dirname(fileURLToPath(import.meta.url)) + +// Runs the real Worker (src/worker/index.ts) on local Miniflare with in-memory D1/R2. +// Outbound fetches (OpenAI, Resend) are intercepted: nothing leaves the machine and no paid AI call is made. + +export const SESSION_SECRET = 'e2e-session-secret-not-real' +export const OPENAI_HOST = 'api.openai.com' +export const RESEND_HOST = 'api.resend.com' + +export type OpenAiMode = + | { kind: 'card'; card: Record } + | { kind: 'http-error'; status: number; body: string } + | { kind: 'garbage' } + +export type TraceEntry = { at: string; kind: 'request' | 'outbound'; method: string; url: string; status?: number; body?: string; note?: string } + +export type Harness = { + mf: Miniflare + openai: { mode: OpenAiMode; calls: number } + trace: TraceEntry[] + signIn(email: string): Promise + anonymous(): Session + r2Keys(): Promise + lastLoginLink(email: string): string | undefined + dispose(): Promise +} + +export type Session = { + email: string + cookie: string + request(path: string, init?: RequestInit & { headers?: Record }): Promise +} + +export { PNG_BYTES } + +export async function startHarness(): Promise { + const root = process.env.CARDCAP_SRC ? resolve(process.env.CARDCAP_SRC) : resolve(here, '..') + const outDir = mkdtempSync(join(tmpdir(), 'cardcap-e2e-')) + const bundle = join(outDir, 'worker.mjs') + await build({ + entryPoints: [join(root, 'src/worker/index.ts')], + outfile: bundle, + bundle: true, + format: 'esm', + platform: 'neutral', + target: 'es2022', + conditions: ['workerd', 'worker'], + external: ['node:*', 'cloudflare:*'], + logLevel: 'silent', + }) + + const trace: TraceEntry[] = [] + const openai = { mode: { kind: 'card', card: {} } as OpenAiMode, calls: 0 } + const sentEmails: Array<{ to: string; text: string }> = [] + + const mf = new Miniflare({ + modules: true, + script: readFileSync(bundle, 'utf8'), + scriptPath: 'worker.mjs', + compatibilityDate: '2026-06-07', + compatibilityFlags: ['nodejs_compat'], + d1Databases: { DB: 'e2e-db' }, + r2Buckets: { CARD_IMAGES: 'e2e-images' }, + bindings: { + SESSION_SECRET, + AI_EXTRACTOR: 'openai', + OPENAI_API_KEY: 'sk-test-not-real', + OPENAI_MODEL: 'test-model', + RESEND_API_KEY: 're_test_not_real', + SENDER_EMAIL: 'test@example.invalid', + }, + outboundService: async (request: Request) => { + const url = new URL(request.url) + const body = request.method === 'GET' ? '' : await request.text() + if (url.hostname === RESEND_HOST) { + const payload = JSON.parse(body) as { to: string[]; text: string } + sentEmails.push({ to: payload.to[0], text: payload.text }) + trace.push({ at: new Date().toISOString(), kind: 'outbound', method: request.method, url: request.url, status: 200, note: 'resend intercepted' }) + return MfResponse.json({ id: 'email-test' }) + } + if (url.hostname === OPENAI_HOST) { + openai.calls += 1 + const mode = openai.mode + trace.push({ at: new Date().toISOString(), kind: 'outbound', method: request.method, url: request.url, note: `openai intercepted (${mode.kind}); request body ${body.length} bytes` }) + if (mode.kind === 'http-error') return new MfResponse(mode.body, { status: mode.status }) + if (mode.kind === 'garbage') return MfResponse.json({ output_text: 'this is not json' }) + return MfResponse.json({ output_text: JSON.stringify(mode.card) }) + } + trace.push({ at: new Date().toISOString(), kind: 'outbound', method: request.method, url: request.url, status: 599, note: 'UNEXPECTED outbound host blocked' }) + return new MfResponse('blocked by e2e harness', { status: 599 }) + }, + }) + await mf.ready + + const origin = 'https://cardcap.test' + + async function send(cookie: string, path: string, init: RequestInit & { headers?: Record } = {}): Promise { + const headers = new Headers(init.headers) + if (cookie) headers.set('cookie', cookie) + const res = (await mf.dispatchFetch(`${origin}${path}`, { ...init, headers, redirect: 'manual' } as never)) as unknown as Response + let snippet = '' + const type = res.headers.get('content-type') || '' + if (/json|text/.test(type)) snippet = (await res.clone().text()).slice(0, 2000) + trace.push({ at: new Date().toISOString(), kind: 'request', method: init.method || 'GET', url: path, status: res.status, body: snippet }) + return res + } + + return { + mf, + openai, + trace, + anonymous: () => ({ email: '', cookie: '', request: (path, init) => send('', path, init) }), + async signIn(email) { + const before = sentEmails.length + const res = await send('', '/api/auth/request-link', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email }), + }) + if (res.status !== 200 || sentEmails.length !== before + 1) throw new Error(`login link request failed: ${res.status}`) + const link = sentEmails[sentEmails.length - 1].text.match(/https?:\/\/\S+\/api\/auth\/verify\?token=\w+/)?.[0] + if (!link) throw new Error('no magic link in email') + const verify = await send('', new URL(link).pathname + new URL(link).search) + if (verify.status !== 302) throw new Error(`verify failed: ${verify.status}`) + const cookie = (verify.headers.get('set-cookie') || '').split(';')[0] + if (!cookie.startsWith('cardcap_session=')) throw new Error('no session cookie issued') + return { email, cookie, request: (path, init) => send(cookie, path, init) } + }, + lastLoginLink(email) { + const mail = [...sentEmails].reverse().find((m) => m.to === email) + return mail?.text.match(/https?:\/\/\S+\/api\/auth\/verify\?token=\w+/)?.[0] + }, + async r2Keys() { + const bucket = await mf.getR2Bucket('CARD_IMAGES') + return (await bucket.list()).objects.map((o) => o.key).sort() + }, + async dispose() { + await mf.dispose() + }, + } +} + +export function saveTrace(name: string, trace: TraceEntry[], error?: unknown): void { + const dir = resolve(here, '..', 'test-results', 'e2e-traces', process.env.CARDCAP_TARGET || 'worktree') + mkdirSync(dir, { recursive: true }) + const file = join(dir, `${name.replace(/[^a-z0-9]+/gi, '-').slice(0, 100)}.json`) + writeFileSync(file, JSON.stringify({ test: name, error: error ? String(error) : undefined, trace }, null, 2)) +} + +export function uploadForm(fileName: string, type = 'image/png', bytes: Uint8Array = PNG_BYTES): FormData { + const form = new MfFormData() as unknown as FormData + form.set('file', new Blob([bytes], { type }), fileName) + return form +} + +export function emptyForm(): FormData { + return new MfFormData() as unknown as FormData +} diff --git a/e2e/password.e2e.test.ts b/e2e/password.e2e.test.ts new file mode 100644 index 0000000..c6beb89 --- /dev/null +++ b/e2e/password.e2e.test.ts @@ -0,0 +1,173 @@ +import { afterEach, beforeEach, describe, expect, it, onTestFailed } from 'vitest' +import { saveTrace, startHarness, uploadForm, type Harness, type Session } from './harness' + +// Password sign-in and Realtor text normalization exist only in the canonical working tree (not in main HEAD). + +let h: Harness + +beforeEach(async () => { + h = await startHarness() + h.openai.mode = { + kind: 'card', + card: { name: 'Maria Okafor', company: 'Sunrise Realty Group', role: 'Broker', emails: ['maria@sunrise.example'], phones: ['(602) 555-0142'], website: '', address: '', tags: [], notes: '', confidence: 0.95, needs_review: false }, + } +}) + +afterEach(async () => { + await h.dispose() +}) + +function scenario(name: string, fn: () => Promise) { + it(name, async () => { + onTestFailed((result) => saveTrace(name, h.trace, result.errors?.[0]?.message)) + await fn() + }) +} + +const json = { 'content-type': 'application/json' } +const login = (email: string, password: string) => + h.anonymous().request('/api/auth/password-login', { method: 'POST', headers: json, body: JSON.stringify({ email, password }) }) +const setPassword = (session: Session, password: string) => + session.request('/api/auth/set-password', { method: 'POST', headers: json, body: JSON.stringify({ password }) }) +const cookieOf = (res: Response) => (res.headers.get('set-cookie') || '').split(';')[0] + +describe('password sign-in', () => { + scenario('a signed-in user can set a password, then sign in with it and reach their own contacts', async () => { + const alice = await h.signIn('alice@example.test') + await alice.request('/api/cards/upload', { method: 'POST', body: uploadForm('maria.png') }) + + const set = await setPassword(alice, 'correct horse battery') + expect(set.status).toBe(200) + expect(await set.json()).toEqual({ ok: true }) + + const res = await login('alice@example.test', 'correct horse battery') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true, email: 'alice@example.test' }) + const cookie = cookieOf(res) + expect(cookie.startsWith('cardcap_session=')).toBe(true) + + const viaPassword = await h.anonymous().request('/api/contacts', { headers: { Cookie: cookie } }) + expect(viaPassword.status).toBe(200) + const list = (await viaPassword.json()) as { contacts: Array<{ name: string }> } + expect(list.contacts.map((c) => c.name)).toEqual(['Maria Okafor']) + }) + + scenario('setting a password requires a session', async () => { + const res = await h.anonymous().request('/api/auth/set-password', { method: 'POST', headers: json, body: JSON.stringify({ password: 'correct horse battery' }) }) + expect(res.status).toBe(401) + }) + + scenario('a too-short password is rejected with a clear message and does not enable password login', async () => { + const alice = await h.signIn('alice@example.test') + const res = await setPassword(alice, 'short') + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'Password must be at least 8 characters.' }) + expect((await login('alice@example.test', 'short')).status).toBe(401) + }) + + scenario('wrong password, unknown email and a never-set password all fail identically (no account enumeration)', async () => { + const alice = await h.signIn('alice@example.test') + await h.signIn('carol@example.test') + await setPassword(alice, 'correct horse battery') + + const bodies: string[] = [] + for (const [email, password] of [ + ['alice@example.test', 'wrong password here'], + ['nobody@example.test', 'correct horse battery'], + ['carol@example.test', 'correct horse battery'], + ]) { + const res = await login(email, password) + expect(res.status, email).toBe(401) + expect(res.headers.get('set-cookie'), email).toBeNull() + bodies.push(await res.text()) + } + expect(new Set(bodies).size).toBe(1) + expect(JSON.parse(bodies[0])).toEqual({ error: 'Invalid email or password.' }) + }) + + scenario('malformed sign-in requests are rejected with 400', async () => { + expect((await login('not-an-email', 'correct horse battery')).status).toBe(400) + expect((await login('alice@example.test', '')).status).toBe(400) + }) + + scenario('email matching at sign-in is case-insensitive', async () => { + const alice = await h.signIn('alice@example.test') + await setPassword(alice, 'correct horse battery') + const res = await login('Alice@Example.TEST', 'correct horse battery') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true, email: 'alice@example.test' }) + }) + + scenario('one user password cannot sign in to, or be changed by, another user', async () => { + const alice = await h.signIn('alice@example.test') + const bob = await h.signIn('bob@example.test') + await setPassword(alice, 'alice password one') + await setPassword(bob, 'bob password one') + + expect((await login('bob@example.test', 'alice password one')).status).toBe(401) + await setPassword(bob, 'bob password two') + expect((await login('alice@example.test', 'alice password one')).status).toBe(200) + expect((await login('bob@example.test', 'bob password one')).status).toBe(401) + expect((await login('bob@example.test', 'bob password two')).status).toBe(200) + }) + + scenario('the password is stored hashed, never in plaintext, and is not returned by any response', async () => { + const alice = await h.signIn('alice@example.test') + const secret = 'correct horse battery' + const set = await setPassword(alice, secret) + expect(await set.text()).not.toContain(secret) + + const db = await h.mf.getD1Database('DB') + const rows = (await db.prepare('SELECT password_hash FROM user_passwords').all<{ password_hash: string }>()).results + expect(rows).toHaveLength(1) + expect(rows[0].password_hash.startsWith('pbkdf2-sha256$')).toBe(true) + expect(rows[0].password_hash).not.toContain(secret) + expect(await (await alice.request('/api/me')).text()).not.toContain(rows[0].password_hash) + }) + + scenario('a password session sees only its own contacts', async () => { + const alice = await h.signIn('alice@example.test') + const bob = await h.signIn('bob@example.test') + await alice.request('/api/cards/upload', { method: 'POST', body: uploadForm('maria.png') }) + await setPassword(bob, 'bob password one') + const cookie = cookieOf(await login('bob@example.test', 'bob password one')) + const list = (await (await h.anonymous().request('/api/contacts', { headers: { Cookie: cookie } })).json()) as { contacts: unknown[] } + expect(list.contacts).toEqual([]) + }) +}) + +describe('Realtor text normalization on save and export', () => { + scenario('trademark marks and casing are cleaned when a contact is saved, and exports show the cleaned text', async () => { + const alice = await h.signIn('alice@example.test') + const created = await alice.request('/api/contacts', { + method: 'POST', + headers: json, + body: JSON.stringify({ + name: 'Dana Reyes', + company: 'Sunrise REALTOR® Group', + role: 'realtor', + email: 'dana@sunrise.example', + tags: ['REALTORS', 'Luxury', 'realtor®'], + notes: 'Top REALTOR™ in Mesa', + status: 'Active', + }), + }) + expect(created.status).toBe(201) + + const list = (await (await alice.request('/api/contacts')).json()) as { contacts: Array> } + expect(list.contacts).toHaveLength(1) + expect(list.contacts[0]).toMatchObject({ + company: 'Sunrise Realtor Group', + role: 'Realtor', + tags: ['Realtors', 'Luxury', 'Realtor'], + notes: 'Top Realtor in Mesa', + }) + + expect(await (await alice.request('/api/export.csv')).text()).toBe( + [ + 'name,company,role,email,phones,website,address,tags,status,next_step,notes', + 'Dana Reyes,Sunrise Realtor Group,Realtor,dana@sunrise.example,,,,"Realtors; Luxury; Realtor",Active,,Top Realtor in Mesa', + ].join('\n'), + ) + }) +}) diff --git a/eslint.config.js b/eslint.config.js index df0fee1..a86d063 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist', 'worker-configuration.d.ts']), + globalIgnores(['dist', 'worker-configuration.d.ts', '.claude/**', 'test-results/**', 'playwright-report/**']), { files: ['**/*.{ts,tsx}'], extends: [ @@ -16,7 +16,8 @@ export default defineConfig([ reactRefresh.configs.vite, ], languageOptions: { - globals: globals.browser, + globals: { ...globals.browser, ...globals.node }, + parserOptions: { tsconfigRootDir: import.meta.dirname }, }, }, ]) diff --git a/migrations/0003_password_auth.sql b/migrations/0003_password_auth.sql new file mode 100644 index 0000000..0e54507 --- /dev/null +++ b/migrations/0003_password_auth.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS user_passwords ( + user_id TEXT PRIMARY KEY, + password_hash TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); diff --git a/package-lock.json b/package-lock.json index e876619..f5ef71d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,15 +15,19 @@ "@cloudflare/vite-plugin": "^1.40.0", "@cloudflare/workers-types": "^4.20260607.1", "@eslint/js": "^10.0.1", + "@playwright/test": "1.63.0", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "esbuild": "0.27.3", "eslint": "^10.3.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", "jsdom": "^29.1.1", + "miniflare": "4.20260603.0", + "tsx": "4.23.15", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", "vite": "^8.0.12", @@ -1895,6 +1899,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -4154,6 +4174,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -4540,6 +4589,509 @@ "license": "0BSD", "optional": true }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 9008c11..8877ad1 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,10 @@ "build": "tsc -b && vite build && node scripts/remove-dist-secrets.mjs", "lint": "eslint .", "preview": "vite preview", - "test": "vitest run --config vitest.config.ts" + "test": "vitest run --config vitest.config.ts", + "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:browser": "playwright test", + "test:browser:server": "tsx e2e/browser/server.ts" }, "dependencies": { "react": "^19.2.6", @@ -18,15 +21,19 @@ "@cloudflare/vite-plugin": "^1.40.0", "@cloudflare/workers-types": "^4.20260607.1", "@eslint/js": "^10.0.1", + "@playwright/test": "1.63.0", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "esbuild": "0.27.3", "eslint": "^10.3.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", "jsdom": "^29.1.1", + "miniflare": "4.20260603.0", + "tsx": "4.23.15", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", "vite": "^8.0.12", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..978df27 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e/browser', + testMatch: '**/*.spec.ts', + workers: 1, + fullyParallel: false, + retries: 0, + reporter: [['list']], + outputDir: 'test-results/browser', + use: { baseURL: 'http://127.0.0.1:5199', trace: 'retain-on-failure', screenshot: 'only-on-failure' }, + webServer: { command: 'npm run build && npm run test:browser:server', url: 'http://127.0.0.1:5199', reuseExistingServer: false, timeout: 120_000 }, +}) diff --git a/src/App.css b/src/App.css index c086617..81085d4 100644 --- a/src/App.css +++ b/src/App.css @@ -473,3 +473,52 @@ textarea:focus { opacity: 0.7; margin-top: 16px; } + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 20; + display: grid; + place-items: center; + padding: 18px; + background: rgba(31, 37, 45, 0.34); +} + +.modal-panel { + width: min(100%, 420px); + display: grid; + gap: 14px; + padding: 18px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.modal-head, +.modal-actions { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.modal-head h2, +.modal-head p { + margin: 0; +} + +.modal-head h2 { + font-size: 18px; +} + +.modal-head p { + color: var(--muted); + font-size: 13px; +} + +.modal-actions { + align-items: center; + justify-content: flex-start; + flex-wrap: wrap; +} diff --git a/src/App.tsx b/src/App.tsx index ab07879..59f37f2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,9 @@ function App() { const [busy, setBusy] = useState(false) const [authRequired, setAuthRequired] = useState(false) const [userEmail, setUserEmail] = useState('') + const [passwordPanelOpen, setPasswordPanelOpen] = useState(false) + const [accountPassword, setAccountPassword] = useState('') + const [accountPasswordConfirm, setAccountPasswordConfirm] = useState('') const [notice, setNotice] = useState('') const [error, setError] = useState('') const fileInput = useRef(null) @@ -170,10 +173,33 @@ function App() { await apiJson<{ ok: boolean }>('/api/auth/logout', 'POST').catch(() => undefined) setUserEmail('') setContacts([]) + setPasswordPanelOpen(false) + setAccountPassword('') + setAccountPasswordConfirm('') selectContact(null) setAuthRequired(true) } + async function saveAccountPassword() { + if (accountPassword !== accountPasswordConfirm) { + setError('Passwords do not match.') + return + } + setBusy(true) + setError('') + try { + await apiJson<{ ok: boolean }>('/api/auth/set-password', 'POST', { password: accountPassword }) + setPasswordPanelOpen(false) + setAccountPassword('') + setAccountPasswordConfirm('') + setNotice('Password saved.') + } catch (err) { + setError(messageFrom(err)) + } finally { + setBusy(false) + } + } + async function saveDraft() { if (!draft) return if (!draft.name.trim()) { @@ -292,15 +318,76 @@ function App() { iContact CSV {userEmail && ( - + <> + + + )} {(notice || error) &&
{error || notice}
} + {passwordPanelOpen && ( +
setPasswordPanelOpen(false)}> +
event.stopPropagation()} + > +
+
+

Account Password

+

{userEmail}

+
+ +
+ + +
+ + +
+
+
+ )} + {authRequired && void handleSignedIn()} />} {!authRequired && ( diff --git a/src/components/LoginGate.tsx b/src/components/LoginGate.tsx index 33246cd..9a7ea85 100644 --- a/src/components/LoginGate.tsx +++ b/src/components/LoginGate.tsx @@ -17,8 +17,12 @@ type LoginGateProps = { onSignedIn: () => void } +type LoginMode = 'password' | 'link' + export function LoginGate({ onSignedIn }: LoginGateProps) { const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [mode, setMode] = useState('password') const [sent, setSent] = useState(false) const [busy, setBusy] = useState(false) const [error, setError] = useState('') @@ -46,7 +50,11 @@ export function LoginGate({ onSignedIn }: LoginGateProps) { }, []) useEffect(() => { - if (!siteKey || !widgetRef.current) return + if (mode !== 'link') { + widgetIdRef.current = '' + return + } + if (!siteKey || !widgetRef.current || sent) return const renderWidget = () => { if (widgetRef.current && window.turnstile && !widgetIdRef.current) { widgetIdRef.current = window.turnstile.render(widgetRef.current, { @@ -65,7 +73,25 @@ export function LoginGate({ onSignedIn }: LoginGateProps) { script.async = true script.onload = renderWidget document.head.appendChild(script) - }, [siteKey]) + }, [mode, sent, siteKey]) + + async function signInWithPassword() { + const trimmed = email.trim() + if (!trimmed || !password) return + setBusy(true) + setError('') + try { + await apiJson<{ ok: boolean; email: string }>('/api/auth/password-login', 'POST', { + email: trimmed, + password, + }) + onSignedIn() + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + } async function requestLink() { const trimmed = email.trim() @@ -93,9 +119,56 @@ export function LoginGate({ onSignedIn }: LoginGateProps) {

Sign in to CardCap

- {!sent && ( + {mode === 'password' && ( + <> + + + + + + )} + {mode === 'link' && !sent && ( <> -

Enter your email and we'll send you a one-time sign-in link. No password needed.

+

Enter your email and we'll send you a one-time sign-in link.