mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
Onboarding persists a draft to `localStorage`, including `createdCompanyId`. That key is scoped per browser origin, not per account, so a browser that has already run onboarding hands the stored company id to the next session on the same origin — whoever is signed in. Every downstream call then targets a company that account may not own: goals, agents and issues are created there, and requests fail with authorization errors. `restoreOnboardingState` now returns a saved draft only when the signed-in account owns the company it names. Otherwise the draft is discarded and the stale blob removed. The wizard splits into a gate and an inner component because the inner one has ~20 `useState(saved?.x ?? default)` initializers, and an initializer runs only on the first render. Mounting before the restored draft is final locks every field to its default with no way back, so the gate waits for the company list while it is loading. Ownership is judged only against a list that actually answered. Any company query error makes it undecidable, whatever the list contains — the companies cache is not account-scoped and survives sign-out, so a failed refetch after an account switch can leave the previous account's companies in hand, and trusting a non-empty list there would hand one account's draft to the next. Nothing is restored and nothing is deleted in that state; the next successful load decides. Judging the draft and mounting the wizard are separate questions. The gate withholds the wizard only while the list is *loading*, never on error: the companies query sets `retry: false`, and with no companies the dashboard offers a "Get Started" button wired to onboarding, so blocking there would make that button do nothing at all. Mounting costs the draft nothing, because the persist effect is itself gated on the wizard being open. All four draft-storage call sites — read, write, cleanup, reset — go through one guarded helper. Storage access throws outright where a browser denies it, and each site sits in a render, an effect or a close handler, so an escaping exception took down something the customer was using. Lands the work from #9900 by @stubbi, whose two commits are included unchanged with their authorship. The rebase, the error-path handling and the storage guards are mine. Follow-up filed separately: sign-out should remove account-scoped cached data rather than invalidating two keys. This change is defensive and protects onboarding only. Tested: ui typecheck clean; 73 pass across the seven onboarding suites; full ui suite 3952 pass, with one timezone-dependent IssueProperties failure present on master in a file this does not touch. All CI gates green; Greptile 5/5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
36 lines
1.5 KiB
TypeScript
36 lines
1.5 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { restoreOnboardingState } from "./onboarding-state";
|
|
|
|
const companies = [{ id: "company-new" }];
|
|
|
|
describe("restoreOnboardingState", () => {
|
|
it("returns null for unusable input", () => {
|
|
expect(restoreOnboardingState(null, companies)).toBeNull();
|
|
expect(restoreOnboardingState("nonsense", companies)).toBeNull();
|
|
});
|
|
|
|
it("discards state whose company the user does not own", () => {
|
|
const saved = { step: 4, companyName: "Old Co", createdCompanyId: "company-old" };
|
|
expect(restoreOnboardingState(saved, companies)).toBeNull();
|
|
});
|
|
|
|
it("keeps state for a company the user does own", () => {
|
|
const saved = { step: 4, companyName: "New Co", createdCompanyId: "company-new" };
|
|
expect(restoreOnboardingState(saved, companies)?.companyName).toBe("New Co");
|
|
});
|
|
|
|
it("keeps in-progress state that has no company yet", () => {
|
|
const saved = { step: 1, companyName: "Draft" };
|
|
expect(restoreOnboardingState(saved, companies)?.companyName).toBe("Draft");
|
|
});
|
|
|
|
it("discards a company id against an empty (settled) companies list", () => {
|
|
// Per the CONTRACT in the JSDoc, callers must only invoke this once
|
|
// companies have settled. An empty settled list legitimately means the
|
|
// account owns no companies, so a saved company id is discarded exactly
|
|
// like an unowned one.
|
|
const saved = { step: 4, createdCompanyId: "company-new" };
|
|
expect(restoreOnboardingState(saved, [])).toBeNull();
|
|
});
|
|
});
|