mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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}
|
||||
|
||||
@@ -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"]);
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user