fix: critical first-login soft-lock in usage survey overlay (#392)

* fix: prevent UsageSurveyOverlay from soft-locking the first-login password-change flow

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp

* fix: prevent double feedback submission when the settings write fails

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp

* refactor: consolidate feedback enums into packages/shared as a single source of truth

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp

* feat: add ARIA semantics, dismiss-button guard, and shared auth-route list to UsageSurveyOverlay

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp

* test: cover the submit-failure retry path and a persona-only minimal payload

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp
This commit is contained in:
SnapOtter
2026-07-02 18:37:12 +08:00
committed by GitHub
parent bd1838e40b
commit ca076f91fd
11 changed files with 442 additions and 190 deletions
+2 -5
View File
@@ -10,6 +10,7 @@ import { I18nProvider } from "./contexts/i18n-context";
import { useAuth } from "./hooks/use-auth";
import { useMobile } from "./hooks/use-mobile";
import { initAnalytics, isAnalyticsActive, optOut, track } from "./lib/analytics";
import { AUTH_GUARD_UNGATED_PATHS } from "./lib/auth-routes";
import { useAnalyticsStore } from "./stores/analytics-store";
// Lazy-load all pages so each page's JS (and its icons/deps) is only
@@ -100,11 +101,7 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
}
// Don't guard the login or change-password pages
if (
location.pathname === "/login" ||
location.pathname === "/change-password" ||
location.pathname === "/privacy"
) {
if (AUTH_GUARD_UNGATED_PATHS.has(location.pathname)) {
return <>{children}</>;
}
@@ -11,15 +11,19 @@ import {
Video,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import { useTranslation } from "@/contexts/i18n-context";
import { useAuth } from "@/hooks/use-auth";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { apiGet, apiPut } from "@/lib/api";
import { AUTH_GUARD_UNGATED_PATHS } from "@/lib/auth-routes";
import {
type FeedbackImportantArea,
type FeedbackUsageType,
promptVariantForSource,
shouldShowUsageSurvey,
submitFeedback,
surveyIdForSource,
} from "@/lib/feedback";
import { cn } from "@/lib/utils";
import { useAnalyticsStore } from "@/stores/analytics-store";
@@ -42,7 +46,8 @@ const IMPORTANT_AREAS: { value: FeedbackImportantArea; Icon: typeof Image; wide?
export function UsageSurveyOverlay() {
const { t } = useTranslation();
const { role } = useAuth();
const { role, mustChangePassword } = useAuth();
const location = useLocation();
const analyticsConfig = useAnalyticsStore((s) => s.config);
const analyticsConfigLoaded = useAnalyticsStore((s) => s.configLoaded);
const containerRef = useRef<HTMLDivElement>(null);
@@ -51,15 +56,23 @@ export function UsageSurveyOverlay() {
const [usageType, setUsageType] = useState<FeedbackUsageType | null>(null);
const [importantAreas, setImportantAreas] = useState<FeedbackImportantArea[]>([]);
const [submitting, setSubmitting] = useState(false);
const [dismissing, setDismissing] = useState(false);
const busy = submitting || dismissing;
const submittedAnswerKeyRef = useRef<string | null>(null);
const eligibleAuthState = role === "admin" && !mustChangePassword;
const eligibleRoute = !AUTH_GUARD_UNGATED_PATHS.has(location.pathname);
useEffect(() => {
if (role !== "admin") return;
if (!eligibleAuthState || !eligibleRoute) return;
apiGet<{ settings: Record<string, string> }>("/v1/settings")
.then((data) => setSettings(data.settings))
.catch(() => setSettings({}));
}, [role]);
}, [eligibleAuthState, eligibleRoute]);
const visible =
eligibleAuthState &&
eligibleRoute &&
settings !== null &&
shouldShowUsageSurvey({
settings,
@@ -83,30 +96,45 @@ export function UsageSurveyOverlay() {
}
async function handleContinue() {
if (!usageType || submitting) return;
if (!usageType || busy) return;
setSubmitting(true);
const answerKey = JSON.stringify({ usageType, importantAreas: [...importantAreas].sort() });
try {
await submitFeedback({
source: "onboarding",
surveyId: "onboarding-usage-v1",
promptVariant: "onboarding-overlay-v1",
usageType,
importantAreas,
});
if (submittedAnswerKeyRef.current !== answerKey) {
await submitFeedback({
source: "onboarding",
surveyId: surveyIdForSource("onboarding"),
promptVariant: promptVariantForSource("onboarding"),
usageType,
importantAreas,
});
submittedAnswerKeyRef.current = answerKey;
}
await recordSettingsKey("onboarding.usageSurvey.answeredAt");
} catch {
// Submission failed (network/auth). Leave the overlay visible so the
// admin can retry instead of silently losing their answer.
// admin can retry instead of silently losing their answer. If this
// exact answer already submitted successfully (submittedAnswerKeyRef
// matches), a retry only retries the settings write, so the same
// answer never gets submitted twice, but a genuinely different answer
// always submits fresh.
} finally {
setSubmitting(false);
}
}
function handleDismiss() {
// Same reasoning as the handleContinue catch above: a failed write just
// means the overlay stays visible next time, which is an acceptable,
// low-stakes fallback.
void recordSettingsKey("onboarding.usageSurvey.dismissedAt").catch(() => {});
async function handleDismiss() {
if (busy) return;
setDismissing(true);
try {
await recordSettingsKey("onboarding.usageSurvey.dismissedAt");
} catch {
// Same reasoning as handleContinue's catch: a failed write just means
// the overlay stays visible next time, an acceptable low-stakes
// fallback.
} finally {
setDismissing(false);
}
}
if (!visible) return null;
@@ -121,7 +149,10 @@ export function UsageSurveyOverlay() {
>
<div className="w-full max-w-md space-y-6">
<div className="flex flex-col items-center text-center gap-3">
<div className="h-11 w-11 rounded-full bg-primary flex items-center justify-center text-xl">
<div
aria-hidden="true"
className="h-11 w-11 rounded-full bg-primary flex items-center justify-center text-xl"
>
🦦
</div>
<h1 id="usage-survey-title" className="text-lg font-semibold text-foreground">
@@ -129,11 +160,18 @@ export function UsageSurveyOverlay() {
</h1>
</div>
<div className="grid grid-cols-2 gap-2">
<div
role="radiogroup"
aria-labelledby="usage-survey-title"
className="grid grid-cols-2 gap-2"
>
{USAGE_TYPES.map(({ value, Icon, wide }) => (
// biome-ignore lint/a11y/useSemanticElements: styled button with icon and label acting as an ARIA radio, not a native input
<button
key={value}
type="button"
role="radio"
aria-checked={usageType === value}
onClick={() => setUsageType(value)}
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm font-medium text-start transition-colors",
@@ -143,24 +181,30 @@ export function UsageSurveyOverlay() {
wide && "col-span-2 justify-center",
)}
>
<Icon className="h-4 w-4 shrink-0" />
<Icon aria-hidden="true" className="h-4 w-4 shrink-0" />
{t.feedback.usageTypes[value]}
</button>
))}
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">
<p id="usage-survey-tools-label" className="text-sm font-medium text-foreground">
{t.onboarding.usageSurveyToolsLabel}{" "}
<span className="text-xs font-normal text-muted-foreground">
{t.onboarding.pickAnyHint}
</span>
</p>
<div className="grid grid-cols-2 gap-2">
{/* biome-ignore lint/a11y/useSemanticElements: plain group wrapper for toggle buttons, a fieldset would disrupt the grid layout */}
<div
role="group"
aria-labelledby="usage-survey-tools-label"
className="grid grid-cols-2 gap-2"
>
{IMPORTANT_AREAS.map(({ value, Icon, wide }) => (
<button
key={value}
type="button"
aria-pressed={importantAreas.includes(value)}
onClick={() => toggleArea(value)}
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm font-medium text-start transition-colors",
@@ -170,7 +214,7 @@ export function UsageSurveyOverlay() {
wide && "col-span-2 justify-center",
)}
>
<Icon className="h-4 w-4 shrink-0" />
<Icon aria-hidden="true" className="h-4 w-4 shrink-0" />
{t.feedback.importantAreas[value]}
</button>
))}
@@ -181,7 +225,7 @@ export function UsageSurveyOverlay() {
<button
type="button"
onClick={handleContinue}
disabled={!usageType || submitting}
disabled={!usageType || busy}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{t.onboarding.continueLabel}
@@ -189,6 +233,7 @@ export function UsageSurveyOverlay() {
<button
type="button"
onClick={handleDismiss}
disabled={busy}
className="w-full text-center text-xs text-muted-foreground hover:text-foreground hover:underline"
>
{t.feedback.dontAskAgain}
+6
View File
@@ -0,0 +1,6 @@
// Paths where AuthGuard (App.tsx) renders its children without applying
// auth checks. Anything mounted globally inside AuthGuard's children (e.g.
// UsageSurveyOverlay) must treat these routes as ineligible for logic that
// assumes normal auth state, since sessions here may be mid-login,
// mid-password-change, or otherwise restricted.
export const AUTH_GUARD_UNGATED_PATHS = new Set(["/login", "/change-password", "/privacy"]);
+23 -48
View File
@@ -1,19 +1,28 @@
import type {
FeedbackErrorCategory,
FeedbackFrictionArea,
FeedbackImportantArea,
FeedbackInstallMethod,
FeedbackSentiment,
FeedbackSource,
FeedbackSurveyId,
FeedbackType,
FeedbackUsageType,
} from "@snapotter/shared";
import { apiPost } from "@/lib/api";
export type FeedbackSource =
| "global"
| "tool_result"
| "failed_job"
| "admin_installer"
| "search_miss"
| "onboarding";
export type FeedbackSurveyId =
| "global-feedback-v1"
| "tool-result-v1"
| "failed-job-v1"
| "admin-install-v1"
| "search-miss-v1"
| "onboarding-usage-v1";
export type {
FeedbackErrorCategory,
FeedbackFrictionArea,
FeedbackImportantArea,
FeedbackInstallMethod,
FeedbackSentiment,
FeedbackSource,
FeedbackSurveyId,
FeedbackType,
FeedbackUsageType,
};
export type FeedbackPromptVariant =
| "nav-v1"
| "inline-v1"
@@ -22,40 +31,6 @@ export type FeedbackPromptVariant =
| "search-empty-v1"
| "search-results-v1"
| "onboarding-overlay-v1";
export type FeedbackSentiment = "great" | "okay" | "issue" | "missing" | "bug" | "idea" | "other";
export type FeedbackType = "bug" | "feature_request" | "confusing_ux" | "performance" | "other";
export type FeedbackInstallMethod = "docker" | "docker_compose" | "source" | "cloud" | "other";
export type FeedbackUsageType =
| "personal"
| "team_internal"
| "business_workflow"
| "education"
| "evaluating";
export type FeedbackImportantArea =
| "images"
| "pdf_docs"
| "video_audio"
| "batch_workflows"
| "ai_tools";
export type FeedbackFrictionArea =
| "smooth"
| "docker"
| "environment_variables"
| "auth"
| "storage"
| "workers"
| "ai_tools"
| "docs"
| "performance"
| "other";
export type FeedbackErrorCategory =
| "validation_error"
| "upload_error"
| "processing_error"
| "timeout"
| "unsupported_format"
| "worker_unavailable"
| "unknown";
export interface FeedbackPayload {
source: FeedbackSource;