From 4a9c434cd1865a9071a411c808ceb82c21f27962 Mon Sep 17 00:00:00 2001 From: Lukas <80832688+0skater0@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:03:13 +0200 Subject: [PATCH] fix: show the login for signed-out full-screen view links and return to them (#1669) A ?view= link opened without a session spun forever: the gate rendered the bare view when the session check failed, so the plugin runtime never started and the view never registered. A regular browser now keeps the link in sessionStorage, shows the normal login and opens the link again after any login, including redirect logins. Only same-origin paths with a view are restored. The desktop app, embedded sign-in frames and guest views keep their current behavior. --- src/main.tsx | 29 +++- src/ui/lib/view-link-resume.ts | 56 +++++++ src/ui/tests/lib/view-link-resume.test.ts | 25 +++ src/ui/tests/main.test.tsx | 186 ++++++++++++++++++++++ 4 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 src/ui/lib/view-link-resume.ts create mode 100644 src/ui/tests/lib/view-link-resume.test.ts create mode 100644 src/ui/tests/main.test.tsx diff --git a/src/main.tsx b/src/main.tsx index ea0ed8e93..9e7090dc1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,6 +6,8 @@ import "./ui/index.css"; import { ThemeProvider } from "@/components/theme-provider"; import "./ui/i18n/i18n"; import { isElectron } from "@/lib/electron"; +import { isEmbeddedAuthFrame } from "@/lib/embedded-frame"; +import { rememberViewLink, resumeViewLink } from "@/lib/view-link-resume"; import { getEmbeddedServerFailure } from "@/lib/embedded-server-status"; import { Toaster } from "@/components/sonner"; import { @@ -84,6 +86,15 @@ function FullscreenApp() { } } +/** + * A signed-out browser gets the normal login for a full-screen link and comes + * back to it afterwards. The desktop app and the embedded sign-in frames + * hand a login to their own shell, so they keep the bare view. + */ +function signsInForViewLink(): boolean { + return !isElectron() && !isEmbeddedAuthFrame(); +} + function FullscreenAppGate() { const { t } = useTranslation(); const [ready, setReady] = useState(false); @@ -109,7 +120,16 @@ function FullscreenAppGate() { if (!cancelled) setReady(true); }) .catch(() => { - if (!cancelled) setAuthFailed(true); + if (cancelled) return; + if (signsInForViewLink()) { + rememberViewLink(); + window.history.replaceState( + {}, + document.title, + window.location.pathname, + ); + } + setAuthFailed(true); }); return () => { @@ -118,7 +138,7 @@ function FullscreenAppGate() { }, []); if (authFailed) { - return ; + return signsInForViewLink() ? : ; } if (!ready) { @@ -181,6 +201,7 @@ function App() { // Non-fatal: WebSocket connections will fall back to cookie auth } } + if (resumeViewLink()) return; setPhase("loading-app"); await Promise.all([ startPluginRuntime() @@ -262,6 +283,10 @@ function App() { function handleLogin(u: string) { loggingOutRef.current = false; clearDesktopManualLogout(); + if (resumeViewLink()) { + setPhase("loading-app"); + return; + } setAuthUsername(u); setPhase("loading-app"); void (async () => { diff --git a/src/ui/lib/view-link-resume.ts b/src/ui/lib/view-link-resume.ts new file mode 100644 index 000000000..ed39c72aa --- /dev/null +++ b/src/ui/lib/view-link-resume.ts @@ -0,0 +1,56 @@ +/** + * Brings a signed-out full-screen `?view=` link back after sign-in. The link + * waits in sessionStorage, which survives a redirect login in the same tab, + * so no return path is ever sent to the server. + */ + +const PENDING_VIEW_LINK_KEY = "termix-pending-view-link"; + +/** The value as a same-origin path with a `view` parameter, or null. */ +export function toViewLinkPath(value: unknown): string | null { + if (typeof value !== "string") return null; + if (!value.startsWith("/") || value.startsWith("//")) return null; + let url: URL; + try { + url = new URL(value, window.location.origin); + } catch { + return null; + } + if (url.origin !== window.location.origin) return null; + if (!url.searchParams.get("view")) return null; + return `${url.pathname}${url.search}${url.hash}`; +} + +export function rememberViewLink(): void { + const { pathname, search, hash } = window.location; + const path = toViewLinkPath(`${pathname}${search}${hash}`); + if (!path) return; + try { + sessionStorage.setItem(PENDING_VIEW_LINK_KEY, path); + } catch { + // Without storage the user just lands on the app after signing in. + } +} + +/** The remembered link, cleared whether or not it is still valid. */ +export function consumeViewLink(): string | null { + try { + const stored = sessionStorage.getItem(PENDING_VIEW_LINK_KEY); + sessionStorage.removeItem(PENDING_VIEW_LINK_KEY); + return toViewLinkPath(stored); + } catch { + return null; + } +} + +/** + * Opens the remembered link, if there is one. The navigation waits a tick so + * history updates the login callback makes right after sign-in cannot cancel + * it. + */ +export function resumeViewLink(): boolean { + const path = consumeViewLink(); + if (!path) return false; + setTimeout(() => window.location.replace(path), 0); + return true; +} diff --git a/src/ui/tests/lib/view-link-resume.test.ts b/src/ui/tests/lib/view-link-resume.test.ts new file mode 100644 index 000000000..ecf37509c --- /dev/null +++ b/src/ui/tests/lib/view-link-resume.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { consumeViewLink, toViewLinkPath } from "@/lib/view-link-resume"; + +describe("toViewLinkPath", () => { + it.each([ + ["/?view=example-plugin.request&request=123", true], + ["/termix/?view=example-plugin.request#top", true], + ["//evil.com/?view=x", false], + ["/\\evil.com/?view=x", false], + ["https://evil.com/?view=x", false], + ["/foo", false], + ["/?view=", false], + ["not a url", false], + ])("%s -> %s", (value, accepted) => { + expect(toViewLinkPath(value)).toBe(accepted ? value : null); + }); +}); + +describe("consumeViewLink", () => { + it("clears the stored link even when it is invalid", () => { + sessionStorage.setItem("termix-pending-view-link", "//evil.com/?view=x"); + expect(consumeViewLink()).toBeNull(); + expect(sessionStorage.getItem("termix-pending-view-link")).toBeNull(); + }); +}); diff --git a/src/ui/tests/main.test.tsx b/src/ui/tests/main.test.tsx new file mode 100644 index 000000000..685c1d663 --- /dev/null +++ b/src/ui/tests/main.test.tsx @@ -0,0 +1,186 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const KEY = "termix-pending-view-link"; + +const mocks = vi.hoisted(() => ({ + rendered: null as ReactNode, + isElectron: vi.fn(() => false), + getStoredAuth: vi.fn(), + getUserInfo: vi.fn(), + fetchGuestViews: vi.fn(), + startPluginRuntime: vi.fn(async () => {}), + standaloneViewFor: vi.fn(), +})); + +vi.mock("react-dom/client", () => ({ + createRoot: () => ({ + render: (node: ReactNode) => { + mocks.rendered = node; + }, + }), +})); +vi.mock("@/lib/client-cache-version", () => ({ + prepareClientCacheVersion: () => Promise.resolve(), +})); +vi.mock("@/i18n/i18n", () => ({})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@/components/theme-provider", () => ({ + ThemeProvider: ({ children }: { children: ReactNode }) => children, +})); +vi.mock("@/contexts/BrandingContext", () => ({ + BrandingProvider: ({ children }: { children: ReactNode }) => children, +})); +vi.mock("@/contexts/UiPreferencesContext", () => ({ + UiPreferencesProvider: ({ children }: { children: ReactNode }) => children, +})); +vi.mock("@/components/sonner", () => ({ Toaster: () => null })); +vi.mock("@/hooks/use-service-worker", () => ({ useServiceWorker: () => {} })); +vi.mock("@/lib/electron-wheel-zoom", () => ({ + installElectronWheelZoomGuard: () => {}, +})); +vi.mock("@/lib/electron", () => ({ isElectron: mocks.isElectron })); +vi.mock("@/lib/embedded-server-status", () => ({ + getEmbeddedServerFailure: async () => null, +})); +vi.mock("@/auth/Auth", () => ({ + Auth: ({ onLogin }: { onLogin: (username: string) => void }) => ( + + ), + getStoredAuth: mocks.getStoredAuth, + clearStoredAuth: vi.fn(), + markDesktopManualLogout: vi.fn(), + clearDesktopManualLogout: vi.fn(), +})); +vi.mock("@/main-axios", () => ({ + getUserInfo: mocks.getUserInfo, + getCurrentToken: vi.fn(async () => null), + appReadyPromise: Promise.resolve(), +})); +vi.mock("@/plugin-host/loader", () => ({ + fetchGuestViews: mocks.fetchGuestViews, + startPluginRuntime: mocks.startPluginRuntime, + stopPluginRuntime: vi.fn(async () => {}), +})); +vi.mock("@/plugin-host/plugin-store", () => ({ + settledPromise: async () => {}, +})); +vi.mock("@/plugin-host/shell-bridge", () => ({ resetShellBridge: vi.fn() })); +vi.mock("@/hooks/use-permissions", () => ({ + preloadPermissions: async () => {}, +})); +vi.mock("@/api/open-tabs-api", () => ({ + getUserPreferences: async () => undefined, +})); +vi.mock("@/shell/tab-registry", () => ({ + standaloneViewFor: mocks.standaloneViewFor, +})); +vi.mock("@/plugin-host/PluginViewPlaceholder", () => ({ + PluginViewPlaceholder: ({ viewId }: { viewId: string }) => ( +
placeholder:{viewId}
+ ), +})); +vi.mock("@/onboarding/startup", () => ({ + resolveStartupOnboarding: async () => null, +})); +vi.mock("@/AppShell", () => ({ AppShell: () =>
app shell
})); +vi.mock("@/onboarding/OnboardingStage", () => ({ + OnboardingStage: () => null, +})); + +const originalLocation = window.location; +let replace: ReturnType; + +/** Loads the entry point fresh, the way a page load would. */ +async function openPage(url: string) { + window.history.replaceState({}, "", url); + vi.resetModules(); + mocks.rendered = null; + await import("../../main"); + await vi.waitFor(() => expect(mocks.rendered).not.toBeNull()); + render(<>{mocks.rendered}); +} + +beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + localStorage.clear(); + mocks.isElectron.mockReturnValue(false); + mocks.getStoredAuth.mockReturnValue(null); + mocks.getUserInfo.mockRejectedValue({ status: 401 }); + mocks.fetchGuestViews.mockResolvedValue(["example-plugin.guest"]); + mocks.standaloneViewFor.mockImplementation((view: string) => ({ + standalone: () =>
standalone:{view}
, + })); + replace = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { + get origin() { + return originalLocation.origin; + }, + get pathname() { + return originalLocation.pathname; + }, + get search() { + return originalLocation.search; + }, + get hash() { + return originalLocation.hash; + }, + replace, + }, + }); +}); + +afterEach(() => { + cleanup(); + Object.defineProperty(window, "location", { + configurable: true, + value: originalLocation, + }); +}); + +describe("full-screen view links", () => { + it("shows the login when signed out and returns to the link after it", async () => { + await openPage("/?view=example-plugin.request&request=123"); + + const signIn = await screen.findByText("sign in"); + expect(sessionStorage.getItem(KEY)).toBe( + "/?view=example-plugin.request&request=123", + ); + fireEvent.click(signIn); + + await vi.waitFor(() => + expect(replace).toHaveBeenCalledWith( + "/?view=example-plugin.request&request=123", + ), + ); + expect(sessionStorage.getItem(KEY)).toBeNull(); + }); + + it("keeps the bare view in the desktop app", async () => { + mocks.isElectron.mockReturnValue(true); + mocks.standaloneViewFor.mockReturnValue(undefined); + await openPage("/?view=example-plugin.request"); + + expect( + await screen.findByText("placeholder:example-plugin.request"), + ).toBeTruthy(); + expect(screen.queryByText("sign in")).toBeNull(); + expect(sessionStorage.getItem(KEY)).toBeNull(); + }); + + it("serves a guest view without a session or a login", async () => { + await openPage("/?view=example-plugin.guest&room=1"); + + expect( + await screen.findByText("standalone:example-plugin.guest"), + ).toBeTruthy(); + expect(mocks.getUserInfo).not.toHaveBeenCalled(); + expect(sessionStorage.getItem(KEY)).toBeNull(); + }); +});