Skip to content

fix(wizard): harden onboarding wizard skip with 6-layer defense#2895

Merged
tphakala merged 4 commits intomainfrom
fix/wizard-skip-robustness
Apr 29, 2026
Merged

fix(wizard): harden onboarding wizard skip with 6-layer defense#2895
tphakala merged 4 commits intomainfrom
fix/wizard-skip-robustness

Conversation

@tphakala
Copy link
Copy Markdown
Owner

@tphakala tphakala commented Apr 29, 2026

Summary

  • Make the wizard dismiss endpoint always public so it works pre-auth (the endpoint only writes a version string, no data exposure)
  • Auto-seed last_seen_version for existing installs that predate wizard tracking, using 5 detection signals: location configured, audio sources present, security enabled, detections exist, notes exist
  • Close the wizard modal immediately before firing the dismiss API call (non-blocking UX)
  • Retry the dismiss POST once on failure after 2 seconds
  • Enable the close button and Escape key as an escape hatch

Context

Users upgrading from older versions hit a failure chain where: the V2 detections table is empty (data in legacy tables), the dismiss endpoint requires auth but the wizard shows pre-auth, and skip()/complete() block on the failed API call before closing the modal. PR #2889 mitigated the localStorage side but the underlying server-side and UI-blocking failures remained.

This fix adds defense in depth so no single failure can trap the user in the wizard.

Test plan

  • Fresh Docker install: wizard shows once, skip works, never returns
  • Existing install upgrade (with configured lat/lng or audio sources): wizard does NOT show
  • Private browsing: dismiss persists server-side
  • Clear localStorage + reload: wizard doesn't return (server has version)
  • Escape key and close button both dismiss the wizard
  • Security enabled: dismiss works without authentication

Fixes #2892

Summary by CodeRabbit

  • New Features

    • Wizard dialog now shows a close button for easier dismissal.
  • Bug Fixes

    • Wizard dismissal will automatically retry if a network request fails.
  • Improvements

    • More robust detection of existing installs to improve when the wizard appears.
    • Wizard dismissal endpoint is now accessible without requiring authentication.
    • Skip/Complete actions reset UI immediately while dismissal runs in background.
  • Tests

    • Updated tests to reflect synchronous skip/complete behavior.

Make the wizard dismiss endpoint always public so it works pre-auth,
auto-seed last_seen_version for existing installs that predate wizard
tracking, detect existing installs via multiple signals (location,
audio sources, security config, detections, notes), close the modal
immediately before firing the dismiss API call, retry the dismiss
POST once on failure, and enable the close button as an escape hatch.

Closes #2892
Update determineWizardState docstring to reflect new isExistingInstall
logic. Add dismiss retry to dismissOnly() for consistency with
dismiss(), completing the multi-site fix.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 29, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: ca52b66b-7a19-4aea-8135-4b7c68d7c74c

📥 Commits

Reviewing files that changed from the base of the PR and between 6516702 and c34fbca.

📒 Files selected for processing (1)
  • internal/api/v2/app_wizard_test.go

Walkthrough

Modal now shows a visible close button; skip/complete immediately reset frontend wizard state and fire-and-forget a dismiss POST that retries once after 2s on failure; backend exposes POST /app/wizard/dismiss publicly and adds heuristics to auto-seed app_metadata.last_seen_version for existing installs to suppress the wizard.

Changes

Cohort / File(s) Summary
Frontend: dialog & state
frontend/src/lib/desktop/features/wizard/WizardDialog.svelte, frontend/src/lib/desktop/features/wizard/wizardState.svelte.ts, frontend/src/lib/desktop/features/wizard/wizardState.svelte.test.ts
Wizard dialog now renders a visible close button. skip()/complete() signatures changed from async→void and immediately reset local wizard state while calling void dismiss() (fire-and-forget). dismiss()/dismissOnly() now schedule a retry POST after 2s on API failure. Tests updated to call skip/complete synchronously.
Backend: route & install detection
internal/api/v2/app.go
POST /app/wizard/dismiss registered without auth/conditional middleware (public). New helpers isExistingInstall and hasNotes broaden heuristics (coords, realtime sources, security flags, detections/notes) to auto-seed app_metadata.last_seen_version and suppress the wizard for existing installs; auto-seed write failures are logged and do not block /app/config.

Sequence Diagram(s)

sequenceDiagram
  participant UI as "Browser UI\n(WizardDialog)"
  participant WS as "Frontend\nWizardState"
  participant API as "Backend API\nPOST /app/wizard/dismiss"
  participant DB as "Database / app_metadata"

  UI->>WS: user clicks Skip/Done/Close
  WS->>WS: reset local wizard state (flow/currentStep/completed/isActive)
  WS->>API: POST /app/wizard/dismiss (fire-and-forget)
  API->>DB: write dismissal / update last_seen_version (may auto-seed)
  alt DB write succeeds
    DB-->>API: 200 OK
    API-->>WS: 200 OK
  else DB write fails
    DB-->>API: error
    API-->>API: log warning / return error
    WS-->>API: retry POST after 2s
    API->>DB: retry write
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the primary change: hardening the wizard skip functionality with multiple defensive layers as implemented across frontend, backend, and retry logic.
Linked Issues check ✅ Passed All six coding objectives from issue #2892 are addressed: (1) auto-seed last_seen_version prevents wizard showing for existing installs, (2) server-side persistence via endpoint always being public, (3) non-blocking skip/complete immediately reset UI state before API call, (4) multiple detection signals handle legacy data, (5) dismiss endpoint is now public and works without auth, (6) close button/Escape key support added and dismiss API retries.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the wizard skip/complete flow: frontend modal visibility and state management, backend endpoint authentication, wizard state detection logic, and retry handling. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/wizard-skip-robustness

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request enables the close button in the wizard dialog and updates the frontend state management to include a retry mechanism for dismissal and non-blocking calls. On the backend, the wizard dismissal endpoint is now public, and the logic for determining the wizard state has been enhanced to detect existing installations by checking various configuration signals, auto-seeding the version if necessary. Feedback suggests centralizing table name resolution in the backend and extracting duplicated retry logic in the frontend to improve maintainability and reduce code duplication.

Comment thread internal/api/v2/app.go
Comment on lines +290 to +293
tableName := "notes"
if tp, ok := c.V2Manager.(interface{ TablePrefix() string }); ok && tp.TablePrefix() != "" {
tableName = tp.TablePrefix() + tableName
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for resolving the table name with an optional prefix is duplicated here and in hasZeroDetections. Centralize this parameter resolution before branching logic to ensure consistency and avoid code duplication across different paths.

References
  1. Centralize parameter normalization or resolution before branching logic to ensure consistency and avoid code duplication across different paths.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. The table name + prefix pattern is 3 lines and appears in exactly 2 places. Extracting a helper for 2 callers in a focused bug fix would add indirection without meaningful DRY benefit. Will revisit if more callers appear.

Comment on lines +67 to +69
setTimeout(() => {
api.post(WIZARD_DISMISS_ENDPOINT).catch(() => {});
}, 2000);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The retry logic for the dismiss API call is duplicated between dismiss and dismissOnly. To avoid code duplication and improve maintainability, extract this shared logic and associated state management into a reusable composable function.

References
  1. To avoid code duplication and improve maintainability, extract shared logic, such as action handlers and associated state management for modals and API calls, into a reusable composable function.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. The two functions have different signatures (dismiss captures currentVersion from module state, dismissOnly takes an explicit version parameter). Extracting a shared retry wrapper would need to account for these differences. Keeping them inline is clearer for 2 callers.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/api/v2/app.go (1)

296-300: Consider logging errors in hasNotes for debugging consistency.

hasZeroDetections logs a warning when the database query fails (Line 256), but hasNotes silently swallows errors. While returning false on error is the safe default (avoids incorrectly showing the wizard), logging the error would maintain consistency and aid debugging.

♻️ Suggested improvement
 	err := c.V2Manager.DB().WithContext(ctx).Table(tableName).
 		Select("1").Limit(1).Scan(&exists).Error
 	if err != nil {
+		c.logWarnIfEnabled("Failed to check notes for wizard state", logger.Error(err))
 		return false
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/v2/app.go` around lines 296 - 300, The hasNotes function
currently swallows DB errors; update it to log the error like hasZeroDetections
does: when the call to
c.V2Manager.DB().WithContext(ctx).Table(tableName).Select("1").Limit(1).Scan(&exists).Error
returns a non-nil err, call the same logger used in hasZeroDetections (e.g.,
c.Logger or the component logger) to emit a warning or error with context
(include tableName and err) and then return false; follow the same logging
message pattern used in hasZeroDetections to keep consistency.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/src/lib/desktop/features/wizard/wizardState.svelte.ts`:
- Around line 113-121: The skip() and complete() functions currently return void
and call dismiss() fire-and-forget, so tests that await
wizardState.skip()/complete() won't wait for the async dismiss() to finish;
change both functions to async and await the dismiss() call (e.g., make function
skip(): Promise<void> and await dismiss(); then call resetState()) so awaiting
them in tests properly waits for the API/mock to be invoked; keep resetState()
after the awaited dismiss() to preserve behavior.

---

Nitpick comments:
In `@internal/api/v2/app.go`:
- Around line 296-300: The hasNotes function currently swallows DB errors;
update it to log the error like hasZeroDetections does: when the call to
c.V2Manager.DB().WithContext(ctx).Table(tableName).Select("1").Limit(1).Scan(&exists).Error
returns a non-nil err, call the same logger used in hasZeroDetections (e.g.,
c.Logger or the component logger) to emit a warning or error with context
(include tableName and err) and then return false; follow the same logging
message pattern used in hasZeroDetections to keep consistency.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 87b92a9f-da37-47bf-9170-38640a96c394

📥 Commits

Reviewing files that changed from the base of the PR and between 804bcfa and 8293a67.

📒 Files selected for processing (3)
  • frontend/src/lib/desktop/features/wizard/WizardDialog.svelte
  • frontend/src/lib/desktop/features/wizard/wizardState.svelte.ts
  • internal/api/v2/app.go

Comment thread frontend/src/lib/desktop/features/wizard/wizardState.svelte.ts
Add error logging to hasNotes() for consistency with hasZeroDetections().
Remove stale await from tests after skip()/complete() became sync.
@tphakala
Copy link
Copy Markdown
Owner Author

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the onboarding wizard by making the dismissal endpoint public and refining the logic to detect existing installations. On the frontend, the wizard dialog now includes a close button, and the state transitions for skipping or completing the wizard are now synchronous with a retry mechanism for the dismissal API call. A review comment suggests using context.Background() for the database write during the auto-seeding process in the GetAppConfig handler to ensure the operation completes even if the client disconnects.

Comment thread internal/api/v2/app.go
Comment on lines +219 to 226
if c.isExistingInstall(ctx) {
// Auto-seed: existing install predates wizard tracking.
// Intentional write inside GET handler (idempotent upsert, fires once per install).
if err := c.appMetadataRepo.Set(ctx, appMetadataKeyLastSeenVersion, c.Settings.Version); err != nil {
c.logWarnIfEnabled("Failed to auto-seed last_seen_version", logger.Error(err))
}
return false, false, c.Settings.Version
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Performing a database write (appMetadataRepo.Set) within a GET handler (GetAppConfig) violates the principle of idempotency. Specifically, using the request context (ctx) means the write will be canceled if the client disconnects. Since this is a background migration task, consider using context.Background() to ensure the write completes even if the connection is dropped. This aligns with the repository's approach to incremental refactoring where a lifecycle-bound context is not yet available.

References
  1. When performing incremental refactoring, a context.Context parameter can be temporarily introduced with context.Background(). The full implementation with a lifecycle-bound context should follow in a subsequent change and the intent should be documented.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping request context. The upsert is a single-row operation (sub-millisecond), and if it fails due to context cancellation, the next request retries it (idempotent). Using context.Background() inside a request handler goes against Go conventions and would mask legitimate context propagation. The practical risk of client disconnect during a sub-ms upsert is negligible.

Comment thread internal/api/v2/app.go
Comment on lines +274 to +276
}
if !c.hasZeroDetections(ctx) {
return true
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The logic in determineWizardState was changed to skip the "what's new" wizard for existing installs, but the corresponding test case was not updated, which will cause a CI failure.
Severity: MEDIUM

Suggested Fix

Update the test case TestDetermineWizardState/upgrade from unknown — no last_seen_version, has detections in app_wizard_test.go. Change the expectation for wantNew from true to false to align with the new, intended behavior of the determineWizardState function, which now skips the wizard for existing installations.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: internal/api/v2/app.go#L274-L276

Potential issue: The function `determineWizardState` was modified to skip the "what's
new" wizard for existing installations where `lastSeenVersion` is empty. This is
achieved by a check in `isExistingInstall()` which now returns `true` if there are any
detections. However, the test case `TestDetermineWizardState/upgrade from unknown — no
last_seen_version, has detections` in `app_wizard_test.go` was not updated to reflect
this intentional change. It still asserts that `wantNew` should be `true`, but the new
logic correctly returns `false`. This mismatch will cause the test to fail during CI,
blocking the pull request from being merged.

Also affects:

  • internal/api/v2/app.go:92~102

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Fixed in c34fbca - renamed test case and updated expectations to match the new auto-seed behavior.

Rename "upgrade from unknown" test case to "existing install
auto-seeds" and update expectations: with detections present,
isExistingInstall now returns true, triggering auto-seed instead
of showing the what's-new wizard.
Comment thread internal/api/v2/app.go
Comment on lines +298 to +302
if err != nil {
c.logWarnIfEnabled("Failed to check notes for wizard state", logger.Error(err))
return false
}
return exists != 0
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The hasNotes() function queries a non-existent notes table in the v2 database, causing the check to always fail and return false.
Severity: LOW

Suggested Fix

Remove the hasNotes() check from isExistingInstall() as it queries a table that no longer exists in the v2 schema. The check is dead code that generates log noise and is not a reliable signal for detecting an existing installation.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: internal/api/v2/app.go#L298-L302

Potential issue: The function `hasNotes()` in `internal/api/v2/app.go` attempts to query
a table named `notes` in the v2 database to determine if the installation is new.
However, the v2 database schema does not contain a `notes` table; this table was part of
the legacy schema and its data is now in the `detections` table. As a result, the query
always fails, causing `hasNotes()` to always return `false` and log a warning. This
renders one of the five checks in `isExistingInstall()` non-functional, potentially
causing the setup wizard to incorrectly appear on existing installations under specific
edge-case conditions.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The notes table is the legacy V1 table that exists in SQLite databases being migrated to V2 (see startup.go:301). For upgrading users, it contains their detection data. For fresh installs, it doesn't exist, the query errors silently (caught), and returns false - which is the correct behavior. This signal catches exactly the users it's designed for.

@tphakala tphakala merged commit 5a25f99 into main Apr 29, 2026
25 checks passed
@tphakala tphakala deleted the fix/wizard-skip-robustness branch April 29, 2026 10:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: Onboarding wizard - impossible to skip or complete

1 participant