Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ name: Verify
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
Expand All @@ -19,6 +20,7 @@ jobs:
cache: npm
- run: npm ci
- run: npm run lint
- run: npx oxfmt --check
- run: npm run typecheck
- run: npm test
- run: npm run build
Expand Down
28 changes: 14 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,25 +44,25 @@ The homepage renders without Supabase. The inquiry form and the admin need it.
| `OWNER_EMAIL` | script | setup only | Only `scripts/provision-owner.mjs` reads it. |
| `E2E_OWNER_EMAIL`, `E2E_OWNER_PASSWORD`, `E2E_BASE_URL` | test | e2e only | Only Playwright reads them. The base URL defaults to `http://localhost:3000`. |

On Vercel, the API also accepts the origins in `VERCEL_URL` and `VERCEL_PROJECT_PRODUCTION_URL`, and reads the client IP from `x-vercel-forwarded-for`. Outside Vercel, every request counts against the same rate limit.
On Vercel, the API also accepts the origins in `VERCEL_URL`, `VERCEL_BRANCH_URL` and `VERCEL_PROJECT_PRODUCTION_URL`, and reads the client IP from `x-vercel-forwarded-for`. Outside Vercel, every request counts against the same rate limit.

Give each environment its own database and secrets. Never add the `NEXT_PUBLIC_` prefix to a server secret.

## Commands

| Command | What it does |
| ------------------- | ----------------------------------------------------------------------------------------- |
| `npm run dev` | Starts the dev server. |
| `npm run lint` | Runs oxlint with type-aware rules. |
| `npm run format` | Formats with oxfmt. |
| `npm run typecheck` | Generates route types, then runs `tsc --noEmit`. |
| `npm test` | Runs validation and Slack tests, and runs the real migrations and RLS policies in PGlite. |
| `npm run test:e2e` | Runs Playwright against a running app and a real Supabase project. |
| `npm run test:public` | Runs public UI regressions against a production build without Supabase credentials. |
| `npm run test:session` | Checks login outage recovery and unauthorized denial against a local mock service. |
| `npm run build` | Builds for production. |

CI (`.github/workflows/verify.yml`) runs lint, typecheck, tests, build, `npm audit` across production and development dependencies at every severity, public UI browser regressions, and local session-recovery checks on every push and pull request. CI does not run the authenticated provider e2e tests.
| Command | What it does |
| ---------------------- | ----------------------------------------------------------------------------------------- |
| `npm run dev` | Starts the dev server. |
| `npm run lint` | Runs oxlint with type-aware rules. |
| `npm run format` | Formats with oxfmt. |
| `npm run typecheck` | Generates route types, then runs `tsc --noEmit`. |
| `npm test` | Runs validation and Slack tests, and runs the real migrations and RLS policies in PGlite. |
| `npm run test:e2e` | Runs Playwright against a running app and a real Supabase project. |
| `npm run test:public` | Runs public UI regressions against a production build without Supabase credentials. |
| `npm run test:session` | Checks login outage recovery and unauthorized denial against a local mock service. |
| `npm run build` | Builds for production. |

CI (`.github/workflows/verify.yml`) runs lint, a formatting check, typecheck, tests, build, `npm audit` across production and development dependencies at every severity, public UI browser regressions, and local session-recovery checks on every pull request and every push to `main`. CI does not run the authenticated provider e2e tests.

To run public UI regressions locally, run `npm run build`, `npx playwright install chromium`, and `npm run test:public`. The tests start their own production server on port 3012.

Expand Down
22 changes: 19 additions & 3 deletions components/inquiry-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,13 @@ function ProjectRange({
);
}

export function InquiryForm({ caseStudy }: { caseStudy?: Study }) {
export function InquiryForm({
caseStudy,
onSent,
}: {
caseStudy?: Study;
onSent?: () => void;
}) {
const id = useId();
const submissionId = useRef<string | null>(null);
const [result, setResult] = useState<Result>({ kind: 'idle' });
Expand Down Expand Up @@ -171,6 +177,7 @@ export function InquiryForm({ caseStudy }: { caseStudy?: Study }) {
) {
setResult({ kind: 'success', reference: body.reference });
submissionId.current = null;
onSent?.();
} else {
const message =
typeof body === 'object' &&
Expand Down Expand Up @@ -371,6 +378,9 @@ export function InquiryButton({
const dialog = useRef<HTMLDialogElement>(null);
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
const id = useId();
// The dialog stays mounted to keep a draft; a sent request gets a fresh form next time.
const [sent, setSent] = useState(false);
const [formKey, setFormKey] = useState(0);
useEffect(() => {
if (!open) return;
dialog.current?.showModal();
Expand All @@ -392,7 +402,13 @@ export function InquiryButton({
ref={dialog}
className="inquiry-dialog"
aria-labelledby={id}
onClose={() => setOpen(false)}
onClose={() => {
setOpen(false);
if (sent) {
setSent(false);
setFormKey((key) => key + 1);
}
}}
>
<button
className="inquiry-close"
Expand All @@ -402,7 +418,7 @@ export function InquiryButton({
×
</button>
<h2 id={id}>Let&apos;s talk about your idea.</h2>
<InquiryForm />
<InquiryForm key={formKey} onSent={() => setSent(true)} />
</dialog>,
portalTarget,
)}
Expand Down
2 changes: 2 additions & 0 deletions lib/crm/origin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export function isAllowedOrigin(request: Request) {
if (process.env.VERCEL) {
for (const host of [
process.env.VERCEL_URL,
// Preview links usually open the branch alias, not the per-deployment URL.
process.env.VERCEL_BRANCH_URL,
process.env.VERCEL_PROJECT_PRODUCTION_URL,
]) {
if (host) allowed.push(`https://${host}`);
Expand Down
4 changes: 1 addition & 3 deletions tests/database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,9 +354,7 @@ test('intake reports whether a submission is new or a retry', async () => {

test('admin_users holds exactly one owner', async () => {
await assert.rejects(
db.query('insert into public.admin_users(user_id) values ($1)', [
stranger,
]),
db.query('insert into public.admin_users(user_id) values ($1)', [stranger]),
/duplicate key/,
);
const owners = await db.query<{ user_id: string }>(
Expand Down
38 changes: 38 additions & 0 deletions tests/e2e/public-ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,41 @@ test('mobile request survives desktop resize without leaving the page locked', a
.click();
await expect(page.locator('body')).not.toHaveCSS('overflow', 'hidden');
});

test('a sent request leaves a fresh form for the next one', async ({
page,
}) => {
await page.route('**/api/requests', (route) =>
route.fulfill({
status: 201,
json: { ok: true, reference: '11111111-1111-4111-8111-111111111111' },
}),
);
await page.goto('/');
const start = page
.getByRole('button', { name: 'Start your project', exact: false })
.first();
await start.click();
const dialog = page.getByRole('dialog');
const name = dialog.getByRole('textbox', {
name: 'Your name *',
exact: true,
});
await name.fill('Fresh form check');
await dialog
.getByRole('textbox', { name: 'Email address *', exact: true })
.fill('fresh@example.com');
await dialog
.getByRole('textbox', { name: 'What would you like to build? *' })
.fill('A fictional project used only by the UI regression test.');
await dialog.getByRole('checkbox', { name: /I agree/ }).check();
await dialog.getByRole('button', { name: /Send project request/ }).click();
await expect(dialog.getByText('Request received.')).toBeVisible();
await dialog
.getByRole('button', { name: 'Close request', exact: true })
.click();
await expect(dialog).not.toBeVisible();
await start.click();
await expect(name).toBeVisible();
await expect(name).toHaveValue('');
});
53 changes: 53 additions & 0 deletions tests/origin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, test } from 'node:test';
import { isAllowedOrigin } from '../lib/crm/origin';

const keys = [
'APP_URL',
'DEPLOYMENT_ORIGIN',
'VERCEL',
'VERCEL_URL',
'VERCEL_BRANCH_URL',
'VERCEL_PROJECT_PRODUCTION_URL',
] as const;
let saved: Record<string, string | undefined>;

beforeEach(() => {
saved = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
for (const key of keys) delete process.env[key];
});
afterEach(() => {
for (const key of keys) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
});

const from = (origin?: string) =>
new Request('https://example.test/api/requests', {
method: 'POST',
headers: origin ? { origin } : {},
});

test('Vercel deployments accept their deployment, branch and production origins', () => {
process.env.APP_URL = 'https://jonasinfocus.com';
process.env.VERCEL = '1';
process.env.VERCEL_URL = 'site-abc123.vercel.app';
process.env.VERCEL_BRANCH_URL = 'site-git-feature.vercel.app';
process.env.VERCEL_PROJECT_PRODUCTION_URL = 'site.vercel.app';
for (const origin of [
'https://jonasinfocus.com',
'https://site-abc123.vercel.app',
'https://site-git-feature.vercel.app',
'https://site.vercel.app',
])
assert.equal(isAllowedOrigin(from(origin)), true, origin);
assert.equal(isAllowedOrigin(from('https://evil.example')), false);
assert.equal(isAllowedOrigin(from('http://site.vercel.app')), false);
});

test('requests without an origin or from an unlisted origin are rejected', () => {
process.env.APP_URL = 'https://jonasinfocus.com';
assert.equal(isAllowedOrigin(from()), false);
assert.equal(isAllowedOrigin(from('https://site-abc123.vercel.app')), false);
});
Loading