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>
32 lines
1.5 KiB
TypeScript
32 lines
1.5 KiB
TypeScript
/**
|
|
* localStorage is scoped per ORIGIN, not per account, so a browser that already
|
|
* ran onboarding will happily hand a previous company's id to a brand new
|
|
* session. Every downstream call then targets the wrong company: goals,
|
|
* agents, and issues get created there, and requests targeting the actual
|
|
* company fail with authorization errors.
|
|
*
|
|
* So restoring is an authorization decision, not a convenience. If the saved
|
|
* company is not one the signed-in user owns, the whole blob is discarded.
|
|
*
|
|
* CONTRACT: `companies` must be the caller's SETTLED companies list (its
|
|
* loading query/context has finished, e.g. `companiesLoading === false`).
|
|
* This function has no way to tell an empty-because-still-loading list apart
|
|
* from an empty-because-the-account-owns-nothing list, so it always treats
|
|
* an empty `companies` as "owns nothing" and discards a saved company id.
|
|
* Callers must not invoke this while companies are still loading, wait for
|
|
* the settled list first, or a legitimate draft gets discarded by mistake.
|
|
*/
|
|
export function restoreOnboardingState(
|
|
raw: unknown,
|
|
companies: Array<{ id: string }>,
|
|
): Record<string, unknown> | null {
|
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
|
|
const saved = { ...(raw as Record<string, unknown>) };
|
|
|
|
const companyId = saved.createdCompanyId;
|
|
if (typeof companyId !== "string" || companyId === "") return saved;
|
|
|
|
return companies.some((c) => c.id === companyId) ? saved : null;
|
|
}
|