diff --git a/apps/web/src/dialogs/resume/import.dialog.test.tsx b/apps/web/src/dialogs/resume/import.dialog.test.tsx
new file mode 100644
index 000000000..4ac905f77
--- /dev/null
+++ b/apps/web/src/dialogs/resume/import.dialog.test.tsx
@@ -0,0 +1,176 @@
+// @vitest-environment happy-dom
+
+import type { AnchorHTMLAttributes, ReactNode } from "react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import { i18n } from "@lingui/core";
+import { I18nProvider } from "@lingui/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { Dialog } from "@reactive-resume/ui/components/dialog";
+import { useDialogStore } from "@/dialogs/store";
+import { ConfirmDialogProvider } from "@/hooks/use-confirm";
+
+const navigate = vi.hoisted(() => vi.fn());
+// Stands in for the navigation TanStack Router performs from inside . Keeping it separate
+// from `navigate` lets a test tell "the router took us away" apart from "the dialog took us away".
+const routerNavigate = vi.hoisted(() => vi.fn());
+
+type MockLinkProps = AnchorHTMLAttributes & {
+ to: string;
+ children: ReactNode;
+};
+
+vi.mock("@tanstack/react-router", () => ({
+ useNavigate: () => navigate,
+ // Mirrors the part of this fix depends on: the router handles the click and navigates
+ // unless something already prevented the default. Without that, a missing preventDefault() in
+ // the dialog would go unnoticed here.
+ Link: ({ to, children, onClick, ...props }: MockLinkProps) => (
+ {
+ onClick?.(event);
+
+ if (event.defaultPrevented) return;
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
+
+ routerNavigate({ to });
+ }}
+ {...props}
+ >
+ {children}
+
+ ),
+}));
+
+vi.mock("@/features/settings/integrations/hooks/use-has-usable-ai-provider", () => ({
+ useHasUsableAiProvider: () => ({ hasUsableProvider: false, isLoading: false }),
+}));
+
+vi.mock("@/libs/orpc/client", () => ({
+ client: {},
+ orpc: { resume: { import: { mutationOptions: () => ({ mutationFn: vi.fn() }) } } },
+}));
+
+const { ImportResumeDialog } = await import("./import");
+
+beforeAll(() => {
+ i18n.loadAndActivate({ locale: "en", messages: {} });
+});
+
+afterEach(() => {
+ navigate.mockReset();
+ routerNavigate.mockReset();
+});
+
+// Drive `open` from the store the way DialogManager does, so closing the dialog actually unmounts it.
+function DialogHarness() {
+ const open = useDialogStore((state) => state.open);
+
+ return (
+
+ );
+}
+
+const renderDialog = () => {
+ useDialogStore.setState({ open: true, activeDialog: null, onBeforeClose: null });
+
+ return render(
+
+
+
+
+
+
+ ,
+ );
+};
+
+// A real "%PDF" header so the dialog auto-detects the type and shows the provider notice.
+const createPdfFile = () =>
+ new File([new Uint8Array([0x25, 0x50, 0x44, 0x46])], "resume.pdf", { type: "application/pdf" });
+
+// The dialog renders through a portal, so query the document rather than the render container.
+async function selectPdfFile() {
+ const input = document.querySelector('input[type="file"]');
+ if (!input) throw new Error("File input not found");
+
+ fireEvent.change(input, { target: { files: [createPdfFile()] } });
+
+ return await screen.findByText("Set up a provider");
+}
+
+describe("ImportResumeDialog — Set up a provider", () => {
+ // https://github.com/amruthpillai/reactive-resume/issues/3307
+ it("confirms before leaving instead of navigating behind the dialog", async () => {
+ renderDialog();
+ const link = await selectPdfFile();
+
+ fireEvent.click(link);
+
+ expect(await screen.findByText("Leave to set up an AI provider?")).toBeInTheDocument();
+ expect(routerNavigate).not.toHaveBeenCalled();
+ expect(navigate).not.toHaveBeenCalled();
+ expect(screen.getByText("Import an existing resume")).toBeInTheDocument();
+ });
+
+ it("stays put and keeps the selected file when the user cancels", async () => {
+ renderDialog();
+ const link = await selectPdfFile();
+
+ fireEvent.click(link);
+ fireEvent.click(await screen.findByText("Stay"));
+
+ await waitFor(() => {
+ expect(screen.queryByText("Leave to set up an AI provider?")).not.toBeInTheDocument();
+ });
+
+ expect(routerNavigate).not.toHaveBeenCalled();
+ expect(navigate).not.toHaveBeenCalled();
+ expect(useDialogStore.getState().open).toBe(true);
+ expect(screen.getByText("resume.pdf")).toBeInTheDocument();
+ });
+
+ it("closes the dialog and navigates once the user confirms", async () => {
+ renderDialog();
+ const link = await selectPdfFile();
+
+ fireEvent.click(link);
+ fireEvent.click(await screen.findByText("Leave"));
+
+ await waitFor(() => {
+ expect(navigate).toHaveBeenCalledWith({ to: "/dashboard/settings/integrations" });
+ });
+
+ expect(useDialogStore.getState().open).toBe(false);
+ await waitFor(() => {
+ expect(screen.queryByText("Set up a provider")).not.toBeInTheDocument();
+ });
+ });
+
+ it("leaves modifier clicks to the browser so the link can open in a new tab", async () => {
+ renderDialog();
+ const link = await selectPdfFile();
+
+ const event = new MouseEvent("click", { bubbles: true, cancelable: true, metaKey: true });
+ fireEvent(link, event);
+
+ expect(event.defaultPrevented).toBe(false);
+ expect(screen.queryByText("Leave to set up an AI provider?")).not.toBeInTheDocument();
+ expect(navigate).not.toHaveBeenCalled();
+ });
+
+ it("leaves middle clicks to the browser too", async () => {
+ renderDialog();
+ const link = await selectPdfFile();
+
+ const event = new MouseEvent("click", { bubbles: true, cancelable: true, button: 1 });
+ fireEvent(link, event);
+
+ expect(event.defaultPrevented).toBe(false);
+ expect(screen.queryByText("Leave to set up an AI provider?")).not.toBeInTheDocument();
+ expect(navigate).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/dialogs/resume/import.tsx b/apps/web/src/dialogs/resume/import.tsx
index 34b845b76..9afb5f43f 100644
--- a/apps/web/src/dialogs/resume/import.tsx
+++ b/apps/web/src/dialogs/resume/import.tsx
@@ -27,6 +27,7 @@ import { Input } from "@reactive-resume/ui/components/input";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { Combobox } from "@/components/ui/combobox";
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
+import { useConfirm } from "@/hooks/use-confirm";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { client, orpc } from "@/libs/orpc/client";
@@ -120,6 +121,7 @@ async function detectImportType(file: File): Promise {
}
export function ImportResumeDialog(_: DialogProps<"resume.import">) {
+ const confirm = useConfirm();
const navigate = useNavigate();
const closeDialog = useDialogStore((state) => state.closeDialog);
@@ -248,6 +250,31 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
// #6: only warn about unsaved changes once a file has actually been chosen — not on a bare type selection.
useFormBlocker(form, { shouldBlock: () => Boolean(file) });
+ // The provider link navigates away while this dialog stays mounted over the new page, so the
+ // unsaved-changes guard (which only runs on a close attempt) fires far too late. Confirm first,
+ // then close and navigate ourselves.
+ const onSetUpProvider = async (event: React.MouseEvent) => {
+ // Modifier and middle clicks open a new tab: the user is not leaving this page, so let the
+ // browser handle the link and keep the dialog exactly as it is.
+ if (event.defaultPrevented || event.button !== 0) return;
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
+
+ event.preventDefault();
+
+ if (file) {
+ const confirmed = await confirm(t`Leave to set up an AI provider?`, {
+ description: t`You'll be taken to the Integrations page. The file you selected won't be imported.`,
+ confirmText: t`Leave`,
+ cancelText: t`Stay`,
+ });
+
+ if (!confirmed) return;
+ }
+
+ closeDialog();
+ await navigate({ to: "/dashboard/settings/integrations" });
+ };
+
return (
@@ -388,7 +415,11 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
size="sm"
variant="secondary"
nativeButton={false}
- render={{t`Set up a provider`}}
+ render={
+
+ {t`Set up a provider`}
+
+ }
/>
)}