mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
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.
This commit is contained in:
1 parent
16f2f509f2
commit
4a9c434cd1
4 files changed
+294
-2
No files matched your search
+27
-2
@@ -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 <FullscreenApp />;
|
||||
return signsInForViewLink() ? <App /> : <FullscreenApp />;
|
||||
}
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }) => (
|
||||
<button onClick={() => onLogin("alice")}>sign in</button>
|
||||
),
|
||||
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 }) => (
|
||||
<div>placeholder:{viewId}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/onboarding/startup", () => ({
|
||||
resolveStartupOnboarding: async () => null,
|
||||
}));
|
||||
vi.mock("@/AppShell", () => ({ AppShell: () => <div>app shell</div> }));
|
||||
vi.mock("@/onboarding/OnboardingStage", () => ({
|
||||
OnboardingStage: () => null,
|
||||
}));
|
||||
|
||||
const originalLocation = window.location;
|
||||
let replace: ReturnType<typeof vi.fn>;
|
||||
|
||||
/** 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: () => <div>standalone:{view}</div>,
|
||||
}));
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user