mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Anchor cloud upstream redirect on URL pathname
After the cloud's OAuth-style 302 callback, the local UI mounted `CloudUpstream` with `selectedCompany` still null because the CompanyContext hadn't hydrated yet. `applyCompanyPrefix(path, null)` returns the unprefixed `/company/settings/cloud-upstream`, the React router refused to match it, and the user was bounced to `/<defaultCompany>/dashboard` with `token_status` stuck at `pending`. Derive the prefix from `location.pathname` first (which is already prefixed because the cloud sent us back to `/<PREFIX>/company/settings/ cloud-upstream?code=...`), and only fall back to the selected-company issue prefix when the URL has none. Same memoized `settingsPath` is used for both the post-finish `replaceState` and the start-connect `redirectUri`, so both halves stay aligned. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
f2f03ce15e
commit
30265f4ef1
2 files changed
+84
-5
No files matched your search
@@ -21,6 +21,16 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
const mockCompanyState = vi.hoisted(() => ({
|
||||
selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" } as
|
||||
| { id: string; name: string; issuePrefix: string | null }
|
||||
| null,
|
||||
selectedCompanyId: "company-1" as string | null,
|
||||
}));
|
||||
const mockLocationState = vi.hoisted(() => ({
|
||||
pathname: "/PAP/company/settings/cloud-upstream",
|
||||
search: "",
|
||||
}));
|
||||
|
||||
vi.mock("@/api/cloudUpstreams", () => ({
|
||||
cloudUpstreamsApi: mockCloudUpstreamsApi,
|
||||
@@ -38,8 +48,8 @@ vi.mock("@/context/BreadcrumbContext", () => ({
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" },
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: mockCompanyState.selectedCompany,
|
||||
selectedCompanyId: mockCompanyState.selectedCompanyId,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -49,7 +59,7 @@ vi.mock("@/lib/router", () => ({
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
useLocation: () => ({ search: "" }),
|
||||
useLocation: () => ({ pathname: mockLocationState.pathname, search: mockLocationState.search }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -68,6 +78,10 @@ describe("CloudUpstream", () => {
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockCompanyState.selectedCompany = { id: "company-1", name: "Paperclip", issuePrefix: "PAP" };
|
||||
mockCompanyState.selectedCompanyId = "company-1";
|
||||
mockLocationState.pathname = "/PAP/company/settings/cloud-upstream";
|
||||
mockLocationState.search = "";
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableCloudSync: true });
|
||||
mockCloudUpstreamsApi.list.mockResolvedValue(stateWithRun(buildRun({ status: "succeeded" })));
|
||||
mockCloudUpstreamsApi.activateEntities.mockImplementation((_connectionId, _runId, input) =>
|
||||
@@ -184,6 +198,68 @@ describe("CloudUpstream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the URL pathname prefix when cleaning up the callback URL with no company context", async () => {
|
||||
mockCompanyState.selectedCompany = null;
|
||||
mockCompanyState.selectedCompanyId = null;
|
||||
mockLocationState.pathname = "/PAP/company/settings/cloud-upstream";
|
||||
mockLocationState.search = "?code=cb-code&state=cb-state";
|
||||
mockCloudUpstreamsApi.list.mockResolvedValue({ connections: [], runs: [] });
|
||||
mockCloudUpstreamsApi.finishConnect.mockResolvedValue({
|
||||
id: "connection-1",
|
||||
companyId: "company-1",
|
||||
remoteUrl: "https://cloud.example/PAP",
|
||||
target: {
|
||||
stackId: "stack-1",
|
||||
stackSlug: "stack",
|
||||
stackDisplayName: "Paperclip Cloud",
|
||||
companyId: "cloud-company-1",
|
||||
primaryHost: "cloud.example",
|
||||
origin: "https://cloud.example",
|
||||
product: "Paperclip Cloud",
|
||||
schemaMajor: 1,
|
||||
maxChunkBytes: 1024,
|
||||
},
|
||||
tokenStatus: "connected",
|
||||
scopes: ["upstream_import:write"],
|
||||
authorizedGlobalUserId: "user-1",
|
||||
expiresAt: null,
|
||||
createdAt: "2026-05-18T18:00:00.000Z",
|
||||
updatedAt: "2026-05-18T18:00:00.000Z",
|
||||
lastRunId: null,
|
||||
});
|
||||
window.localStorage.setItem("paperclip-cloud-upstream-pending-connection", "pending-1");
|
||||
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
|
||||
|
||||
try {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudUpstream />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockCloudUpstreamsApi.finishConnect).toHaveBeenCalledWith({
|
||||
pendingConnectionId: "pending-1",
|
||||
code: "cb-code",
|
||||
state: "cb-state",
|
||||
});
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "/PAP/company/settings/cloud-upstream");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
} finally {
|
||||
replaceStateSpy.mockRestore();
|
||||
window.localStorage.removeItem("paperclip-cloud-upstream-pending-connection");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps retry only for failed or cancelled runs", async () => {
|
||||
mockCloudUpstreamsApi.list.mockResolvedValue(stateWithRun(buildRun({ status: "failed" })));
|
||||
const root = createRoot(container);
|
||||
|
||||
@@ -24,7 +24,7 @@ import { cloudUpstreamsApi } from "@/api/cloudUpstreams";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { applyCompanyPrefix } from "@/lib/company-routes";
|
||||
import { applyCompanyPrefix, extractCompanyPrefixFromPath } from "@/lib/company-routes";
|
||||
import { Link, useLocation } from "@/lib/router";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
@@ -102,7 +102,10 @@ export function CloudUpstream() {
|
||||
const state = callbackParams.get("state");
|
||||
const callbackError = callbackParams.get("error");
|
||||
|
||||
const settingsPath = applyCompanyPrefix("/company/settings/cloud-upstream", selectedCompany?.issuePrefix ?? null);
|
||||
const settingsPath = useMemo(() => {
|
||||
const pathPrefix = extractCompanyPrefixFromPath(location.pathname);
|
||||
return applyCompanyPrefix("/company/settings/cloud-upstream", pathPrefix ?? selectedCompany?.issuePrefix ?? null);
|
||||
}, [location.pathname, selectedCompany?.issuePrefix]);
|
||||
|
||||
const finishMutation = useMutation({
|
||||
mutationFn: (input: { pendingConnectionId: string; code: string; state: string }) =>
|
||||
|
||||
Reference in new issue
Block a user