On a fresh install useAuthOnboarding's effect ran at mount before the setup wizard's 500ms auto-open timer fired. The one-shot ref locked, and the resolved fetch could either stack model onboarding on top of the wizard or never re-trigger after the wizard closed. - Gate the trigger on projectId being set so the wizard owns the bootstrap phase; the auth check only fires once a project exists. - Re-check setupWizardOpen via a ref when the auth fetch resolves to avoid stacking onboarding on top of a wizard opened mid-fetch. - Release the one-shot in that suppressed branch so the effect retries when the wizard closes. Adds two regression tests: fresh-install handoff and mid-fetch wizard suppression. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
111 lines
4.9 KiB
TypeScript
111 lines
4.9 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import { fetchAuthStatus, fetchGlobalSettings } from "../api";
|
|
import { isOnboardingCompleted } from "../components/model-onboarding-state";
|
|
import { trackOnboardingEvent } from "../components/onboarding-events";
|
|
import type { SectionId } from "../components/SettingsModal";
|
|
|
|
export interface UseAuthOnboardingOptions {
|
|
projectId?: string;
|
|
setupWizardOpen: boolean;
|
|
openModelOnboarding: () => void;
|
|
openSettings: (section?: SectionId) => void;
|
|
}
|
|
|
|
/**
|
|
* Runs auth/onboarding checks and opens the appropriate setup modal.
|
|
*
|
|
* This hook implements a one-shot guard: the auto-trigger logic runs at most
|
|
* once per hook instance (regardless of effect re-runs due to dependency changes).
|
|
* This prevents repeat auto-opens on incidental rerenders or project context churn.
|
|
*
|
|
* Trigger behavior:
|
|
* - First-run (onboarding incomplete): opens model onboarding wizard
|
|
* - Completed onboarding + unauthenticated providers: opens Settings → Authentication
|
|
* - Already configured: no auto-open
|
|
*/
|
|
export function useAuthOnboarding({
|
|
projectId,
|
|
setupWizardOpen,
|
|
openModelOnboarding,
|
|
openSettings,
|
|
}: UseAuthOnboardingOptions): void {
|
|
// One-shot guard: prevents the auto-trigger logic from running more than once
|
|
// per hook instance, even if the effect re-runs due to dependency changes.
|
|
const hasTriggeredRef = useRef(false);
|
|
// Track latest setupWizardOpen so the resolved fetch promise can re-check
|
|
// it without becoming a stale-closure read.
|
|
const setupWizardOpenRef = useRef(setupWizardOpen);
|
|
setupWizardOpenRef.current = setupWizardOpen;
|
|
|
|
useEffect(() => {
|
|
// Defer auto-triggering while setup wizard is open.
|
|
// Important: this must run before consuming the one-shot flag.
|
|
if (setupWizardOpen) return;
|
|
// Hold off until the user has a project. On a fresh install the setup
|
|
// wizard opens ~500ms after mount, so without this gate the effect would
|
|
// race ahead, lock the one-shot flag, and (a) potentially stack both
|
|
// modals, or (b) never re-trigger model onboarding once the wizard
|
|
// closes. With a project in scope, either the wizard already finished
|
|
// or it was never going to open.
|
|
if (!projectId) return;
|
|
// Skip if we've already triggered (one-shot guard)
|
|
if (hasTriggeredRef.current) return;
|
|
// Mark as triggered immediately to prevent any race condition on re-runs
|
|
hasTriggeredRef.current = true;
|
|
|
|
let shouldOpenOnboarding = false;
|
|
let shouldOpenSettings = false;
|
|
|
|
fetchAuthStatus()
|
|
.then(({ providers }) => {
|
|
const hasAuthenticatedProvider = providers.some((provider) => provider.authenticated);
|
|
const needsSetup = providers.length > 0 && !hasAuthenticatedProvider;
|
|
|
|
if (needsSetup || (providers.length > 0 && hasAuthenticatedProvider)) {
|
|
return fetchGlobalSettings()
|
|
.then((globalSettings) => {
|
|
const hasDefaultModel = !!(
|
|
globalSettings.defaultProvider && globalSettings.defaultModelId
|
|
);
|
|
// Explicit first-run detection: onboarding is incomplete when
|
|
// modelOnboardingComplete is false or undefined
|
|
const onboardingIncomplete =
|
|
globalSettings.modelOnboardingComplete === false ||
|
|
globalSettings.modelOnboardingComplete === undefined;
|
|
const setupIncomplete = !hasAuthenticatedProvider || !hasDefaultModel;
|
|
|
|
if (onboardingIncomplete && setupIncomplete && !isOnboardingCompleted()) {
|
|
shouldOpenOnboarding = true;
|
|
} else if (!hasAuthenticatedProvider && !isOnboardingCompleted()) {
|
|
// Completed onboarding but no authenticated provider → fallback
|
|
// to Settings Authentication section (only if not locally completed)
|
|
shouldOpenSettings = true;
|
|
}
|
|
});
|
|
}
|
|
})
|
|
.then(() => {
|
|
// Execute after the promise chain resolves. Re-check the wizard:
|
|
// the user (or auto-open logic) may have opened it while the auth
|
|
// fetch was in flight, and we don't want to stack modals.
|
|
if (setupWizardOpenRef.current) {
|
|
// Release the one-shot so the effect can retry once the wizard
|
|
// closes (the effect re-runs on the setupWizardOpen dep flip).
|
|
hasTriggeredRef.current = false;
|
|
return;
|
|
}
|
|
if (shouldOpenOnboarding) {
|
|
trackOnboardingEvent("onboarding:auto-triggered", { trigger: "first-run" });
|
|
openModelOnboarding();
|
|
} else if (shouldOpenSettings) {
|
|
trackOnboardingEvent("onboarding:auto-triggered", { trigger: "missing-provider" });
|
|
openSettings("authentication");
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Fail silently - non-blocking behavior preserves dashboard usability.
|
|
// Onboarding can be manually triggered later via Settings if needed.
|
|
});
|
|
}, [projectId, setupWizardOpen, openModelOnboarding, openSettings]);
|
|
}
|