From 00fd955c96b9047c1dd9bfb2777c6fd1531ae789 Mon Sep 17 00:00:00 2001 From: Rasbandit Date: Tue, 7 Jul 2026 14:56:13 -0600 Subject: [PATCH 1/8] feat(support): issue_reports table + create_report context Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/engram/support.ex | 25 +++++++++ lib/engram/support/issue_report.ex | 32 ++++++++++++ ...0707130000_create_issue_reports_expand.exs | 30 +++++++++++ test/engram/rls_coverage_test.exs | 1 + test/engram/support_test.exs | 51 +++++++++++++++++++ 5 files changed, 139 insertions(+) create mode 100644 lib/engram/support.ex create mode 100644 lib/engram/support/issue_report.ex create mode 100644 priv/repo/migrations/20260707130000_create_issue_reports_expand.exs create mode 100644 test/engram/support_test.exs diff --git a/lib/engram/support.ex b/lib/engram/support.ex new file mode 100644 index 000000000..a5e719860 --- /dev/null +++ b/lib/engram/support.ex @@ -0,0 +1,25 @@ +defmodule Engram.Support do + @moduledoc "User-submitted issue reports." + alias Engram.Repo + alias Engram.Support.IssueReport + + @doc """ + Insert an issue report. `user` supplies the trusted `user_id`; `attrs` are the + client-supplied `"description"`, `"surface"`, `"app_version"`; `meta` carries + server-derived `:vault_id` and `:device_fingerprint`. + """ + def create_report(user, attrs, meta) do + params = %{ + "user_id" => user.id, + "vault_id" => meta[:vault_id], + "device_fingerprint" => meta[:device_fingerprint], + "surface" => attrs["surface"], + "app_version" => attrs["app_version"], + "description" => attrs["description"] + } + + %IssueReport{} + |> IssueReport.changeset(params) + |> Repo.insert() + end +end diff --git a/lib/engram/support/issue_report.ex b/lib/engram/support/issue_report.ex new file mode 100644 index 000000000..7cb093790 --- /dev/null +++ b/lib/engram/support/issue_report.ex @@ -0,0 +1,32 @@ +defmodule Engram.Support.IssueReport do + use Ecto.Schema + import Ecto.Changeset + + # autogenerate: true so Ecto sets id client-side and returns it in the struct + # (the migration's uuidv7() default is a raw-insert fallback). Matches + # oauth/client.ex convention. + @primary_key {:id, :binary_id, autogenerate: true} + @surfaces ~w(plugin web) + + schema "issue_reports" do + field :user_id, :binary_id + field :vault_id, :string + field :surface, :string + field :app_version, :string + field :device_fingerprint, :string + field :description, :string + field :status, :string, default: "open" + timestamps(type: :utc_datetime_usec, updated_at: false) + end + + @cast ~w(user_id vault_id surface app_version device_fingerprint description status)a + @required ~w(user_id surface description)a + + def changeset(report, attrs) do + report + |> cast(attrs, @cast) + |> validate_required(@required) + |> validate_inclusion(:surface, @surfaces) + |> validate_length(:description, min: 1, max: 5000) + end +end diff --git a/priv/repo/migrations/20260707130000_create_issue_reports_expand.exs b/priv/repo/migrations/20260707130000_create_issue_reports_expand.exs new file mode 100644 index 000000000..09c7fbfac --- /dev/null +++ b/priv/repo/migrations/20260707130000_create_issue_reports_expand.exs @@ -0,0 +1,30 @@ +defmodule Engram.Repo.Migrations.CreateIssueReportsExpand do + use Ecto.Migration + + # phase/expand — new table; no backfill. + # + # No RLS (rls_coverage allowlist): reports are founder-triage data. user_id is + # stamped server-side on insert, there is no per-user read path, and triage + # reads across all tenants. See @no_rls_allowlist in rls_coverage_test. + def change do + create table(:issue_reports, primary_key: false) do + add :id, :uuid, primary_key: true, default: fragment("uuidv7()") + add :user_id, references(:users, type: :uuid, on_delete: :delete_all), null: false + add :vault_id, :string + add :surface, :string, null: false + add :app_version, :string + add :device_fingerprint, :string + add :description, :text, null: false + add :status, :string, null: false, default: "open" + add :inserted_at, :timestamptz, null: false, default: fragment("now()") + end + + create index(:issue_reports, [:user_id]) + create index(:issue_reports, [:status, :inserted_at]) + + execute( + "GRANT SELECT, INSERT, UPDATE, DELETE ON issue_reports TO engram_app", + "REVOKE ALL ON issue_reports FROM engram_app" + ) + end +end diff --git a/test/engram/rls_coverage_test.exs b/test/engram/rls_coverage_test.exs index 18ed3c177..f4f8bc40b 100644 --- a/test/engram/rls_coverage_test.exs +++ b/test/engram/rls_coverage_test.exs @@ -19,6 +19,7 @@ defmodule Engram.RlsCoverageTest do account_exports client_logs idempotency_keys + issue_reports client_origin_stats device_authorizations device_refresh_tokens diff --git a/test/engram/support_test.exs b/test/engram/support_test.exs new file mode 100644 index 000000000..0ad326180 --- /dev/null +++ b/test/engram/support_test.exs @@ -0,0 +1,51 @@ +defmodule Engram.SupportTest do + use Engram.DataCase, async: true + alias Engram.Support + alias Engram.Support.IssueReport + + setup do + user = Engram.Factory.insert(:user) + {:ok, user: user} + end + + test "create_report/3 inserts a report scoped to the user", %{user: user} do + params = %{ + "description" => "sync is broken", + "surface" => "plugin", + "app_version" => "1.9.24" + } + + meta = %{vault_id: "vault-123", device_fingerprint: "abc123def456"} + + assert {:ok, %IssueReport{} = report} = Support.create_report(user, params, meta) + assert report.user_id == user.id + assert report.surface == "plugin" + assert report.description == "sync is broken" + assert report.vault_id == "vault-123" + assert report.device_fingerprint == "abc123def456" + assert report.status == "open" + assert Engram.Repo.get(IssueReport, report.id) + end + + test "create_report/3 rejects an unknown surface", %{user: user} do + params = %{"description" => "x", "surface" => "carrier-pigeon", "app_version" => "1"} + + assert {:error, changeset} = + Support.create_report(user, params, %{vault_id: nil, device_fingerprint: "z"}) + + assert "is invalid" in errors_on(changeset).surface + end + + test "create_report/3 rejects an over-long description", %{user: user} do + params = %{ + "description" => String.duplicate("a", 5001), + "surface" => "web", + "app_version" => "1" + } + + assert {:error, changeset} = + Support.create_report(user, params, %{vault_id: nil, device_fingerprint: "z"}) + + assert Keyword.has_key?(changeset.errors, :description) + end +end From 7c29328430860420c9bc4e0315dd07eb19e74747 Mon Sep 17 00:00:00 2001 From: Rasbandit Date: Tue, 7 Jul 2026 15:05:15 -0600 Subject: [PATCH 2/8] feat(support): Discord webhook notify on new report Adds Engram.Notifications.Discord (pure build_report_payload/2 + fire-and-forget notify_report/2, Task.start so a slow/failing Discord never blocks a report submit). Reads DISCORD_WEBHOOK_URL in runtime config, top-level guarded so self-host/dev/test no-op. Wires notify_report into Support.create_report/3 after insert. --- config/runtime.exs | 7 +++ lib/engram/notifications/discord.ex | 50 ++++++++++++++++++++++ lib/engram/support.ex | 8 ++++ test/engram/notifications/discord_test.exs | 35 +++++++++++++++ 4 files changed, 100 insertions(+) create mode 100644 lib/engram/notifications/discord.ex create mode 100644 test/engram/notifications/discord_test.exs diff --git a/config/runtime.exs b/config/runtime.exs index 660e07146..9db3cd001 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -828,6 +828,13 @@ if key = System.get_env("POSTHOG_API_KEY") do posthog_host: System.get_env("POSTHOG_HOST", "https://us.i.posthog.com") end +# Discord webhook for new issue reports (Engram.Notifications.Discord). +# No-op when DISCORD_WEBHOOK_URL is unset, so dev/test/self-host post +# nothing and the notifier short-circuits before any network call. +if url = System.get_env("DISCORD_WEBHOOK_URL") do + config :engram, :discord_webhook_url, url +end + # Pyroscope continuous CPU profiling. Same opt-in shape as Sentry/PostHog: # the worker's child_spec/1 returns :ignore when any of the three required # env vars is missing, so dev/test/self-host emit no profiling traffic and diff --git a/lib/engram/notifications/discord.ex b/lib/engram/notifications/discord.ex new file mode 100644 index 000000000..f815afa9e --- /dev/null +++ b/lib/engram/notifications/discord.ex @@ -0,0 +1,50 @@ +defmodule Engram.Notifications.Discord do + @moduledoc "Fire-and-forget Discord webhook for new issue reports." + require Logger + + @window_seconds 600 + @desc_limit 1500 + + @doc "Post a report to Discord if a webhook URL is configured; otherwise no-op." + def notify_report(report, user_email) do + case Application.get_env(:engram, :discord_webhook_url) do + url when is_binary(url) and url != "" -> + payload = build_report_payload(report, user_email) + Task.start(fn -> post(url, payload) end) + :ok + + _ -> + :ok + end + end + + @doc "Build the Discord message payload for a report. Pure; unit-tested." + def build_report_payload(report, user_email) do + from = DateTime.add(report.inserted_at, -@window_seconds, :second) + to = DateTime.add(report.inserted_at, @window_seconds, :second) + logql = ~s({service_name="engram-backend"} | user_id="#{report.user_id}") + + content = """ + :beetle: **New issue report** (#{report.surface}, v#{report.app_version}) + user: #{user_email} (`#{report.user_id}`) + when: #{DateTime.to_iso8601(report.inserted_at)} + window: #{DateTime.to_iso8601(from)} to #{DateTime.to_iso8601(to)} + logql: `#{logql}` + + > #{String.slice(report.description, 0, @desc_limit)}#{ellipsis(report.description)} + """ + + %{content: content} + end + + defp ellipsis(s) when byte_size(s) > @desc_limit, do: "…" + defp ellipsis(_), do: "" + + defp post(url, payload) do + case Req.post(url, json: payload, receive_timeout: 10_000, retry: :transient, max_retries: 2) do + {:ok, %{status: s}} when s in 200..299 -> :ok + {:ok, %{status: s}} -> Logger.warning("discord webhook non-2xx: #{s}") + {:error, err} -> Logger.warning("discord webhook failed: #{inspect(err)}") + end + end +end diff --git a/lib/engram/support.ex b/lib/engram/support.ex index a5e719860..92d69e787 100644 --- a/lib/engram/support.ex +++ b/lib/engram/support.ex @@ -21,5 +21,13 @@ defmodule Engram.Support do %IssueReport{} |> IssueReport.changeset(params) |> Repo.insert() + |> case do + {:ok, report} -> + Engram.Notifications.Discord.notify_report(report, user.email) + {:ok, report} + + other -> + other + end end end diff --git a/test/engram/notifications/discord_test.exs b/test/engram/notifications/discord_test.exs new file mode 100644 index 000000000..32054afbd --- /dev/null +++ b/test/engram/notifications/discord_test.exs @@ -0,0 +1,35 @@ +defmodule Engram.Notifications.DiscordTest do + use ExUnit.Case, async: true + alias Engram.Notifications.Discord + alias Engram.Support.IssueReport + + defp report do + %IssueReport{ + id: "00000000-0000-0000-0000-000000000001", + user_id: "11111111-1111-1111-1111-111111111111", + surface: "plugin", + app_version: "1.9.24", + description: "sync stalls on large notes", + inserted_at: ~U[2026-07-07 18:00:00.000000Z] + } + end + + test "build_report_payload/2 carries user id, surface, and a query window" do + %{content: content} = Discord.build_report_payload(report(), "todd@example.com") + assert content =~ "plugin" + assert content =~ "1.9.24" + assert content =~ "todd@example.com" + assert content =~ "11111111-1111-1111-1111-111111111111" + assert content =~ "sync stalls on large notes" + # +/- 10 min window around 18:00:00 + assert content =~ "2026-07-07T17:50:00" + assert content =~ "2026-07-07T18:10:00" + end + + test "build_report_payload/2 truncates a long description" do + long = %{report() | description: String.duplicate("x", 3000)} + %{content: content} = Discord.build_report_payload(long, "a@b.com") + assert content =~ "…" + assert String.length(content) < 2200 + end +end From 4a3c2f76af70edc9a88490d513626fd70ee8192b Mon Sep 17 00:00:00 2001 From: Rasbandit Date: Tue, 7 Jul 2026 15:17:38 -0600 Subject: [PATCH 3/8] feat(support): POST /api/reports endpoint (authed, rate-limited) Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/engram/support/issue_report.ex | 1 + .../controllers/report_controller.ex | 48 ++++++++++++ lib/engram_web/plugs/device_fingerprint.ex | 5 ++ lib/engram_web/plugs/host_rewrite.ex | 2 +- lib/engram_web/router.ex | 5 ++ .../controllers/report_controller_test.exs | 75 +++++++++++++++++++ 6 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 lib/engram_web/controllers/report_controller.ex create mode 100644 test/engram_web/controllers/report_controller_test.exs diff --git a/lib/engram/support/issue_report.ex b/lib/engram/support/issue_report.ex index 7cb093790..5d4dd33d9 100644 --- a/lib/engram/support/issue_report.ex +++ b/lib/engram/support/issue_report.ex @@ -1,4 +1,5 @@ defmodule Engram.Support.IssueReport do + @moduledoc "Schema for a user-submitted issue report." use Ecto.Schema import Ecto.Changeset diff --git a/lib/engram_web/controllers/report_controller.ex b/lib/engram_web/controllers/report_controller.ex new file mode 100644 index 000000000..61ed8283e --- /dev/null +++ b/lib/engram_web/controllers/report_controller.ex @@ -0,0 +1,48 @@ +defmodule EngramWeb.ReportController do + @moduledoc "POST /api/reports: authenticated, rate-limited user issue reports." + use EngramWeb, :controller + alias Engram.Support + + # 5 reports per hour per user. + @rate_scale_ms 3_600_000 + @rate_limit 5 + + def create(conn, params) do + user = conn.assigns.current_user + + if rate_limited?(user.id) do + conn |> put_status(429) |> json(%{error: "rate_limited"}) + else + meta = %{ + vault_id: conn |> get_req_header("x-vault-id") |> List.first(), + device_fingerprint: fingerprint(conn) + } + + case Support.create_report(user, params, meta) do + {:ok, report} -> + conn |> put_status(201) |> json(%{report: %{id: report.id, status: report.status}}) + + {:error, changeset} -> + conn |> put_status(422) |> json(%{errors: format_errors(changeset)}) + end + end + end + + defp rate_limited?(user_id) do + case EngramWeb.RateLimiter.hit("report:#{user_id}", @rate_scale_ms, @rate_limit, :other) do + {:allow, _} -> false + {:deny, _} -> true + end + end + + defp fingerprint(conn) do + ua = conn |> get_req_header("user-agent") |> List.first() || "" + EngramWeb.Plugs.DeviceFingerprint.hash_ua(ua) + end + + defp format_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {k, v}, acc -> String.replace(acc, "%{#{k}}", to_string(v)) end) + end) + end +end diff --git a/lib/engram_web/plugs/device_fingerprint.ex b/lib/engram_web/plugs/device_fingerprint.ex index 85f37ce5f..a0f0b6c2f 100644 --- a/lib/engram_web/plugs/device_fingerprint.ex +++ b/lib/engram_web/plugs/device_fingerprint.ex @@ -37,6 +37,11 @@ defmodule EngramWeb.Plugs.DeviceFingerprint do v -> v end + hash_ua(ua) + end + + @doc "12-char lowercase sha256 hex of a User-Agent string. Shared by the report controller." + def hash_ua(ua) when is_binary(ua) do :crypto.hash(:sha256, ua) |> Base.encode16(case: :lower) |> binary_part(0, 12) diff --git a/lib/engram_web/plugs/host_rewrite.ex b/lib/engram_web/plugs/host_rewrite.ex index ef634589d..259f0c539 100644 --- a/lib/engram_web/plugs/host_rewrite.ex +++ b/lib/engram_web/plugs/host_rewrite.ex @@ -32,7 +32,7 @@ defmodule EngramWeb.Plugs.HostRewrite do @api_top_segments ~w( notes folders search vaults attachments oauth mcp auth admin billing tasks health user me onboarding bootstrap api-keys connections tags sync - logs telemetry embed-status openapi + logs telemetry embed-status openapi reports ) # Test-only accessor exposing the @api_top_segments allowlist so the diff --git a/lib/engram_web/router.ex b/lib/engram_web/router.ex index 51e1e96b7..0157f766f 100644 --- a/lib/engram_web/router.ex +++ b/lib/engram_web/router.ex @@ -287,6 +287,11 @@ defmodule EngramWeb.Router do # SPA emits render/sync spans from the very first screen), so it lives in # this scope rather than the vault-scoped/RequireOnboarding pipeline. post "/telemetry/spans", TelemetryController, :create + + # User-submitted issue reports. Must work for onboarding/billing-blocked + # users too (they are exactly who most needs to report a problem), so + # this lives here rather than the vault-scoped/RequireOnboarding pipeline. + post "/reports", ReportController, :create end # Self-host admin scope. 404 under Clerk (RequireAdmin gates on local auth); diff --git a/test/engram_web/controllers/report_controller_test.exs b/test/engram_web/controllers/report_controller_test.exs new file mode 100644 index 000000000..8116ace17 --- /dev/null +++ b/test/engram_web/controllers/report_controller_test.exs @@ -0,0 +1,75 @@ +defmodule EngramWeb.ReportControllerTest do + use EngramWeb.ConnCase, async: false + alias Engram.Accounts + alias Engram.Repo + alias Engram.Support.IssueReport + + setup do + {:ok, user} = Accounts.create_user_with_password("reporter@example.com", "password123") + {:ok, user: user} + end + + defp authed(user) do + jwt = Accounts.generate_jwt(user) + + build_conn() + |> put_req_header("authorization", "Bearer " <> jwt) + |> put_req_header("content-type", "application/json") + |> put_req_header("user-agent", "obsidian/1.5 engram/1.9.24") + |> put_req_header("x-vault-id", "vault-abc") + end + + test "creates a report and stamps identity server-side", %{user: user} do + conn = + authed(user) + |> post( + "/api/reports", + Jason.encode!(%{description: "broken sync", surface: "plugin", app_version: "1.9.24"}) + ) + + assert %{"report" => %{"id" => id, "status" => "open"}} = json_response(conn, 201) + report = Repo.get(IssueReport, id) + assert report.user_id == user.id + assert report.vault_id == "vault-abc" + assert report.surface == "plugin" + assert byte_size(report.device_fingerprint) == 12 + end + + test "rejects unauthenticated requests" do + conn = + build_conn() + |> put_req_header("content-type", "application/json") + |> post( + "/api/reports", + Jason.encode!(%{description: "x", surface: "web", app_version: "1"}) + ) + + assert conn.status in [401, 403] + end + + test "422 on an over-long description", %{user: user} do + conn = + authed(user) + |> post( + "/api/reports", + Jason.encode!(%{ + description: String.duplicate("a", 5001), + surface: "web", + app_version: "1" + }) + ) + + assert json_response(conn, 422)["errors"] + end + + test "429 after exceeding the per-user rate limit", %{user: user} do + body = Jason.encode!(%{description: "spam", surface: "web", app_version: "1"}) + + statuses = + for _ <- 1..6 do + authed(user) |> post("/api/reports", body) |> Map.get(:status) + end + + assert 429 in statuses + end +end From 8679736257dd7ea64b5d565e8342e14bdcee42a8 Mon Sep 17 00:00:00 2001 From: Rasbandit Date: Tue, 7 Jul 2026 15:35:38 -0600 Subject: [PATCH 4/8] feat(settings): report a bug dialog Adds useReportBug() mutation (mirrors useUpdateProfile), a ReportBugDialog (controlled Dialog + plain textarea, no shadcn textarea exists), and a ReportBugSection mounted before DangerZoneSection on the account settings page. --- frontend/src/api/queries.ts | 7 ++ frontend/src/settings/account-page.tsx | 2 + .../account/report-bug-dialog.test.tsx | 30 +++++++ .../settings/account/report-bug-dialog.tsx | 80 +++++++++++++++++++ .../settings/account/report-bug-section.tsx | 21 +++++ 5 files changed, 140 insertions(+) create mode 100644 frontend/src/settings/account/report-bug-dialog.test.tsx create mode 100644 frontend/src/settings/account/report-bug-dialog.tsx create mode 100644 frontend/src/settings/account/report-bug-section.tsx diff --git a/frontend/src/api/queries.ts b/frontend/src/api/queries.ts index 19c435f16..766aa9ece 100644 --- a/frontend/src/api/queries.ts +++ b/frontend/src/api/queries.ts @@ -712,6 +712,13 @@ export function useUpdateProfile() { }); } +export function useReportBug() { + return useMutation({ + mutationFn: (body: { description: string; surface: "web"; app_version: string }) => + api.post<{ report: { id: string; status: string } }>("/reports", body), + }); +} + export function useDeleteSelf() { return useMutation({ mutationFn: async ({ password }) => { diff --git a/frontend/src/settings/account-page.tsx b/frontend/src/settings/account-page.tsx index 2d1e4b921..01d71e57a 100644 --- a/frontend/src/settings/account-page.tsx +++ b/frontend/src/settings/account-page.tsx @@ -4,6 +4,7 @@ import { DangerZoneSection } from "./account/danger-zone-section"; import { EmailSection } from "./account/email-section"; import { PasswordSection } from "./account/password-section"; import { ProfileSection } from "./account/profile-section"; +import { ReportBugSection } from "./account/report-bug-section"; import { SessionsSection } from "./account/sessions-section"; // OAuth providers enabled on this Clerk instance. Confirm against the instance @@ -25,6 +26,7 @@ export default function AccountPage() { + ); diff --git a/frontend/src/settings/account/report-bug-dialog.test.tsx b/frontend/src/settings/account/report-bug-dialog.test.tsx new file mode 100644 index 000000000..3da14924b --- /dev/null +++ b/frontend/src/settings/account/report-bug-dialog.test.tsx @@ -0,0 +1,30 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ReportBugDialog } from "./report-bug-dialog"; + +const mutate = vi.fn(); +vi.mock("@/api/queries", () => ({ + useReportBug: () => ({ mutate, isPending: false }), +})); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +describe("ReportBugDialog", () => { + beforeEach(() => mutate.mockReset()); + + it("submits the typed description with surface web", () => { + render( {}} />); + fireEvent.change(screen.getByPlaceholderText(/what happened/i), { + target: { value: "sync stalls" }, + }); + fireEvent.click(screen.getByRole("button", { name: /send report/i })); + expect(mutate).toHaveBeenCalledWith( + expect.objectContaining({ description: "sync stalls", surface: "web" }), + expect.anything(), + ); + }); + + it("disables submit when the description is empty", () => { + render( {}} />); + expect(screen.getByRole("button", { name: /send report/i })).toBeDisabled(); + }); +}); diff --git a/frontend/src/settings/account/report-bug-dialog.tsx b/frontend/src/settings/account/report-bug-dialog.tsx new file mode 100644 index 000000000..00c93b6ad --- /dev/null +++ b/frontend/src/settings/account/report-bug-dialog.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import { useReportBug } from "@/api/queries"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +const inputClass = + "w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; + +export function ReportBugDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [description, setDescription] = useState(""); + const report = useReportBug(); + + function submit() { + const text = description.trim(); + if (!text) { + return; + } + report.mutate( + { description: text, surface: "web", app_version: import.meta.env.VITE_GIT_SHA ?? "dev" }, + { + onSuccess: () => { + toast.success("Report sent"); + setDescription(""); + onOpenChange(false); + }, + onError: () => toast.error("Could not send report"), + }, + ); + } + + return ( + + + + Report a bug + + Describe what went wrong. We attach your account and a time window so we can pull the + logs. + + +
{ + e.preventDefault(); + submit(); + }} + > +