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
37 changes: 27 additions & 10 deletions app/routes/setup.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @vitest-environment happy-dom
/**
* F74 — first-run setup has to name the step that leaves the agent portal dead.
* ONB-02 — and has to do it without putting deployment notes on the first view.
*
* Publishing agent terms is a manual, deployment-level step in BOTH modes: the
* gate does not branch on `APP_MODE`, no workspace administrator can supply the
Expand All @@ -9,9 +10,12 @@
* thoroughly in `docs/operate/deploy.md`, and nowhere an operator who only runs
* the wizard would ever see it.
*
* The claim under test is that the setup SURFACE names the step and its
* consequence — and that it stays a note, not a gate: the terms are published
* from the command line, so nothing here may block finishing setup.
* The claims under test: the setup SURFACE still names the step and its
* consequence, but inside a disclosure that is closed on first paint, together
* with the Cloudflare secret steps for the setup code; the setup-code field
* itself keeps a short hint that points at it; and none of it is a gate: the
* terms are published from the command line, so nothing here may block
* finishing setup.
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
Expand Down Expand Up @@ -40,22 +44,35 @@ function renderSetup() {
return render(<Stub initialEntries={["/setup"]} />);
}

describe("first-run setup — the agent-terms step", () => {
it("names agent sign-up and what is off until the terms are published", async () => {
describe("first-run setup — the operator notes", () => {
it("holds the agent-terms step and the Cloudflare secret steps in a closed disclosure", async () => {
const { container } = renderSetup();
// Positive control: the surface this assertion is aimed at really did
// render. A bad selector reports "absent" indistinguishably otherwise.
expect(await screen.findByText(/Create Account/i)).toBeTruthy();

expect(container.textContent).toMatch(/agent sign-up/i);
expect(container.textContent).toMatch(/agent terms/i);
const details = container.querySelector("details");
expect(details).not.toBeNull();
expect(details!.hasAttribute("open")).toBe(false);
expect(details!.querySelector("summary")?.textContent).toMatch(/whoever deployed this/i);

const inside = details!.textContent;
expect(inside).toMatch(/agent sign-up/i);
expect(inside).toMatch(/agent terms/i);
expect(inside).toMatch(/operates this deployment/i);
expect(inside).toMatch(/agent-terms:publish/);
expect(inside).toMatch(/SETUP_CODE/);
expect(inside).toMatch(/Variables and Secrets/);
});

it("says only whoever operates the deployment can do it, and points at the command", async () => {
it("keeps the setup-code hint on the field and points at the note", async () => {
const { container } = renderSetup();
await screen.findByText(/Create Account/i);
expect(container.textContent).toMatch(/operates this deployment/i);
expect(container.textContent).toMatch(/agent-terms:publish/);
const details = container.querySelector("details");
const hint = screen.getByText(/at least 6 characters/i).parentElement;
expect(hint).not.toBeNull();
expect(details!.contains(hint)).toBe(false);
expect(hint!.textContent).toMatch(/note below the form/i);
});

it("does not block finishing setup", async () => {
Expand Down
54 changes: 33 additions & 21 deletions app/routes/setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,19 +167,7 @@ export default function SetupPage() {
hint={
<>
<span className="font-medium text-ih-fg-2">{m.auth_setup_code_help_required()}</span>{" "}
{m.auth_setup_code_help_enter_prefix()}{" "}
<code className="px-1 py-0.5 bg-ih-bg-muted rounded text-ih-fg-2 font-mono text-[10px]">SETUP_CODE</code>{" "}
{m.auth_setup_code_help_middle()}{" "}
<span className="font-medium text-ih-fg-2">{m.auth_setup_code_help_settings_path()}</span>{" "}
{m.auth_setup_code_help_suffix()}{" "}
<a
href="https://developers.cloudflare.com/workers/configuration/environment-variables/#add-environment-variables-via-the-dashboard"
target="_blank"
rel="noopener noreferrer"
className="text-ih-primary-text hover:text-ih-primary-600 underline underline-offset-2"
>
{m.auth_setup_code_help_link()}
</a>
{m.auth_setup_code_help_where()}
</>
}
/>
Expand All @@ -195,21 +183,45 @@ export default function SetupPage() {
</Button>
</Form>

{/* F74 — publishing agent terms is a manual, deployment-level step in
{/* Operator-only notes, collapsed (ONB-02): the first screen a new admin
sees is about creating the account, not about deployment secrets or
a CLI. Both notes stay one click away, and the summary names what is
inside so the operator who does need them can tell.
F74 — publishing agent terms is a manual, deployment-level step in
BOTH modes: the gate does not branch on APP_MODE, no workspace
administrator can supply the document, and until it exists agents
reach "Agent sign-up is not available yet" with no way forward. It is
documented thoroughly in docs/operate/deploy.md and nowhere an
operator who only runs this wizard would see it. A note, not a step:
the terms are published from the command line, so nothing here waits
on it. */}
<p className="mt-6 pt-4 border-t border-ih-border text-[12px] text-ih-fg-3">
{m.auth_setup_agent_terms_note()}{" "}
{/* i18n-literal-ok: a shell command. Translating it would produce a
command that does not exist. */}
<code className="px-1 py-0.5 bg-ih-bg-muted rounded text-ih-fg-2 font-mono text-[10px]">npm run agent-terms:publish</code>{" "}
{m.auth_setup_agent_terms_docs()}
</p>
<details className="mt-6 pt-4 border-t border-ih-border text-[12px] text-ih-fg-3">
<summary className="cursor-pointer select-none font-medium text-ih-fg-2 focus-visible:outline-2 focus-visible:outline-ih-primary rounded-sm">
{m.auth_setup_operator_summary()}
</summary>
<p className="mt-3">
{m.auth_setup_code_help_enter_prefix()}{" "}
<code className="px-1 py-0.5 bg-ih-bg-muted rounded text-ih-fg-2 font-mono text-[10px]">SETUP_CODE</code>{" "}
{m.auth_setup_code_help_middle()}{" "}
<span className="font-medium text-ih-fg-2">{m.auth_setup_code_help_settings_path()}</span>{" "}
{m.auth_setup_code_help_suffix()}{" "}
<a
href="https://developers.cloudflare.com/workers/configuration/environment-variables/#add-environment-variables-via-the-dashboard"
target="_blank"
rel="noopener noreferrer"
className="text-ih-primary-text hover:text-ih-primary-600 underline underline-offset-2"
>
{m.auth_setup_code_help_link()}
</a>
</p>
<p className="mt-3">
{m.auth_setup_agent_terms_note()}{" "}
{/* i18n-literal-ok: a shell command. Translating it would produce a
command that does not exist. */}
<code className="px-1 py-0.5 bg-ih-bg-muted rounded text-ih-fg-2 font-mono text-[10px]">npm run agent-terms:publish</code>{" "}
{m.auth_setup_agent_terms_docs()}
</p>
</details>
</div>
</div>
);
Expand Down
1 change: 1 addition & 0 deletions docs/develop/fork-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ deliberate departure rather than work upstream simply has not done yet.
| `wizard` network drop (2026-10-12 QA sweep NET-02; no upstream fix) | `app/routes/inspections.tsx` exports a `clientAction` that runs `guardWizardNetwork` (`app/lib/wizard-submit.ts`): a create whose fetch dies comes back as `{ ok: false, error: { code: "NETWORK" } }`, the shape the wizard already holds open on (`8632b8f7`), so the draft stays and Create is the retry; the agent and client typeaheads get an empty list; any other intent, a redirect or a non-network error is rethrown. The wizard submits nothing when `isOffline()` (`app/lib/network-failure.ts`, which `isNetworkFailure` now also uses) and skips the conflict and holiday loads and the typeahead while offline (read when the debounce fires, not when it is scheduled). Spec: `guardWizardNetwork` in `app/lib/wizard-submit.test.ts`, the offline case in `NewInspectionWizard.test.tsx` | A failed fetcher is routed by React Router to the route's error boundary, which unmounted the wizard with the inspector's draft. `git log main..upstream/main` on the wizard files has only focus-latch and overlay work. The `clientAction` is its own chunk (`splitRouteModules: "enforce"`), fetched on first use, hence the `isOffline()` pre-check. **Known ceiling:** a conflict or holiday load that fails mid-flight while `navigator.onLine` still says true (a bar of signal, no route) still unmounts the wizard. `file-size-baseline.json` raised for `inspections.tsx` (929 -> 934) and `NewInspectionWizard.tsx` (562 -> 571) |
| `editor` session expiry (2026-10-13 QA sweep NET-06; no upstream fix) | `usePresence` (`app/hooks/usePresence.ts`) has a fourth status, `"expired"`: a presence socket that closes without having opened asks `GET /api/auth/me` with the browser's own cookie, and a 401 reads as expired. A 200, an unreachable API or `isOffline()` leaves "Connecting…" / "Reconnecting…". The retries carry on, so logging in from another tab reconnects this one. `MobileSyncPill` and `FooterBar` show that status as `SignInAgainLink`, an anchor to `/login?returnTo=` this page with its `?section=` / `?item=`. The `/login` loader no longer bounces a token past its `exp` to `returnTo` (`isTokenExpired`, now exported from `app/lib/session.server.ts`). Specs: `app/hooks/usePresence.test.ts`, the expired case in `MobileSyncPill.test.tsx`, the NET-06 case in `app/routes/login-saas-bounce.test.ts` | A browser is never told the status of a refused WebSocket handshake, so once the 24-hour token ran out the editor retried under "Reconnecting…" with the edits held in IndexedDB, and the next navigation went to a bare `/login`. `git log main..upstream/main` by keyword (401, session, socket, collab) and on `app/lib/collab` and `app/hooks` has no such change. The check hangs off the presence socket, not the Yjs one: it is the status both readouts already show, and both sockets are refused by the same cookie check (`server/api/inspections/core.ts`, `collab.ts`). The API's JWT middleware lets a request with no cookie through, and `collab/ws` answers a plain GET with 426 before it checks auth, so the socket's own URL cannot be the probe. **Known ceilings:** a token invalidated by a password change but not yet past `exp` is still bounced from `/login` to `returnTo`, where the auth layout now ends the session (ONB-04 row below) and the visitor lands on a bare `/login`, `returnTo` dropped; under saas `/login` redirects to the portal and drops `returnTo`; on an origin where the browser refuses the `__Host-` cookie (plain http that is not `localhost`) the readout says expired |
| `auth` stale session ends at /login (2026-10-13 QA sweep ONB-04; no upstream fix) | The loader in `app/routes/auth-layout.tsx` ends the session (`destroyUserSession`: both cookies cleared, 302 to `/login`) when the API answers its session-context call with 401. Only 401: a 5xx or a thrown fetch still falls back to the default shell. Specs: `app/routes/auth-layout.loader.test.ts`; the end-to-end case sits in `tests/e2e/auth-password-reset.spec.ts` (a token from before a reset gets 302 to `/login`) | A token issued before a password change or reset passes `requireToken` (its `exp` is live) and is refused by the API (`pwchanged:{userId}`), but the loader swallowed that refusal as a "graceful fallback", so the page came back 200 with "Unnamed", `openinspection.dev` and no Dispatch, and a reload did the same. `git log main..upstream/main` by keyword (stale session, pwchanged, password reset) and on the auth-boundary files finds only the TOTP and invite-link fixes, neither of which touches this. **Known ceilings:** only a document load runs the layout loader, so a stale session that navigates client-side between child routes sees each child's own best-effort fallback until the next full load; `returnTo` is not carried; no `?reason=` notice on `/login` (the upstream plan floated `password-changed`, which would need a message in both catalogs) |
| `setup` operator notes collapsed (2026-10-13 QA sweep ONB-02; no upstream fix) | `app/routes/setup.tsx` renders the Cloudflare steps for the setup code (`SETUP_CODE`, Settings → Variables and Secrets, the add-a-secret link) and the agent-terms note with its `npm run agent-terms:publish` command inside one `<details>` that is closed on first paint, summary "For whoever deployed this: the setup code and agent sign-up" (`auth_setup_operator_summary`, both catalogs). The setup-code field keeps "Required (at least 6 characters)." plus `auth_setup_code_help_where`, which points at the note. `app/routes/setup.test.tsx` now asserts that arrangement (the F74 claim changed on purpose: the step is still named on the page, but not in the first view) | The finding: a brand-new admin's first screen ended with CLI language about a feature (agents) they have not met. `git log main..upstream/main` by keyword (setup, agent terms, disclosure) and on `app/routes/setup.tsx` finds only `fa7e6aac` (submit latch, reserved error space), which does not touch the notes. The summary names what is inside rather than saying "advanced", so F74's reason for the note (an operator who only runs this wizard never sees the publish step) still has an on-page answer. `textContent` still includes the closed disclosure, so the old specs' regexes would not have caught the move; the new ones read `details.hasAttribute("open")`. **Known ceilings:** an operator with no setup code needs one click to find where to add it (the hint says so, but does not carry the steps); the disclosure does not remember being opened; a cherry-pick of `fa7e6aac` will conflict in `setup.tsx` around the setup-code `hint` prop (it adds `reserveErrorSpace` just above it): keep both |

## Rules

Expand Down
2 changes: 2 additions & 0 deletions messages/en/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,13 @@
"auth_setup_code_label": "Setup code",
"auth_setup_code_placeholder": "Setup verification code",
"auth_setup_code_help_required": "Required (at least 6 characters).",
"auth_setup_code_help_where": "Set when this was deployed. Where to find or add it is in the note below the form.",
"auth_setup_code_help_enter_prefix": "Enter the value of the",
"auth_setup_code_help_middle": "secret. Don't have one yet? Add it to this Worker in the Cloudflare dashboard under",
"auth_setup_code_help_settings_path": "Settings → Variables and Secrets",
"auth_setup_code_help_suffix": "(type Secret), then refresh this page.",
"auth_setup_code_help_link": "How to add a secret →",
"auth_setup_operator_summary": "For whoever deployed this: the setup code and agent sign-up",
"auth_setup_agent_terms_note": "One thing this page cannot do: agent sign-up stays closed until the agent terms are published. Until then agents are told sign-up is not available, and no workspace administrator can change it — only whoever operates this deployment can publish them, from the command line:",
"auth_setup_agent_terms_docs": "See docs/operate/deploy.md. Finish creating your account first — this is a separate step, and nothing here waits on it.",
"auth_setup_submit_pending": "Creating your account…",
Expand Down
2 changes: 2 additions & 0 deletions messages/es-419/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,13 @@
"auth_setup_code_label": "Código de configuración",
"auth_setup_code_placeholder": "Código de verificación de la configuración",
"auth_setup_code_help_required": "Obligatorio (al menos 6 caracteres).",
"auth_setup_code_help_where": "Se define al desplegar esto. Dónde encontrarlo o agregarlo está en la nota debajo del formulario.",
"auth_setup_code_help_enter_prefix": "Ingrese el valor del secreto",
"auth_setup_code_help_middle": "en este Worker. ¿Todavía no tiene uno? Agréguelo en el panel de Cloudflare, en",
"auth_setup_code_help_settings_path": "Settings → Variables and Secrets",
"auth_setup_code_help_suffix": "(tipo Secret), y luego actualice esta página.",
"auth_setup_code_help_link": "Cómo agregar un secreto →",
"auth_setup_operator_summary": "Para quien desplegó esto: el código de configuración y el registro de agentes",
"auth_setup_agent_terms_note": "Algo que esta página no puede hacer: el registro de agentes permanece cerrado hasta que se publiquen los términos para agentes. Hasta entonces, a los agentes se les indica que el registro no está disponible, y ningún administrador del espacio de trabajo puede cambiarlo: solo quien opera esta instalación puede publicarlos, desde la línea de comandos:",
"auth_setup_agent_terms_docs": "Consulte docs/operate/deploy.md. Termine de crear su cuenta primero: este es un paso aparte y nada de esto depende de él.",
"auth_setup_submit_pending": "Creando su cuenta…",
Expand Down
Loading