Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 66 additions & 7 deletions src/components/VNCApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@ import { useEffect, useRef, useState, useCallback } from "react";
import { useT } from "@/lib/i18n";
import { getTrackedVncKey, type TrackedKey } from "@/lib/vnc-keys";
import { copyToClipboard } from "@/lib/clipboard";
import { JSON_ACCEPT_HEADERS, isAuthExpired } from "@/lib/setup-auth";

type ConnectionStatus = "connecting" | "connected" | "disconnected" | "error";

// Shown whenever a /setup-api/* call comes back 401 or redirected to /login.
const SESSION_EXPIRED_MESSAGE =
"Your setup session has expired. Please sign in again to continue.";

const REMOTE_MODIFIER_RELEASES: TrackedKey[] = [
{ code: "ControlLeft", keysym: 0xffe3 },
{ code: "ControlRight", keysym: 0xffe4 },
Expand All @@ -32,6 +37,9 @@ export default function VNCApp() {
const pressedKeysRef = useRef<Map<string, TrackedKey>>(new Map());
const [status, setStatus] = useState<ConnectionStatus>("connecting");
const [error, setError] = useState<string | null>(null);
// Set when a setup-api call reports the session expired — flips the error
// panel to a "sign in again" action instead of retry/repair.
const [authExpired, setAuthExpired] = useState(false);
const [vncInfo, setVncInfo] = useState<{ host: string; wsPort: number } | null>(null);
// Install/repair flow: when the VNC API is unreachable or reports VNC as
// missing, the user can trigger `install.sh --step vnc_install` through
Expand Down Expand Up @@ -73,8 +81,21 @@ export default function VNCApp() {

const checkVnc = useCallback(async () => {
try {
const res = await fetch("/setup-api/vnc");
const data = await res.json();
const res = await fetch("/setup-api/vnc", { headers: JSON_ACCEPT_HEADERS });
if (isAuthExpired(res)) {
setAuthExpired(true);
setStatus("error");
setError(SESSION_EXPIRED_MESSAGE);
return;
}
// Guard the parse: a non-OK/non-JSON body must not throw a confusing
// "Unexpected token <" from a login page slipping through.
const data = await res.json().catch(() => ({} as { available?: boolean; wsPort?: number; error?: string }));
if (!res.ok) {
setStatus("error");
setError(data.error || `VNC check failed (HTTP ${res.status})`);
return;
}
if (data.available) {
// Keep host/wsPort in the state shape for backwards compatibility but
// the WebSocket URL is actually built below against same-origin.
Expand Down Expand Up @@ -395,6 +416,7 @@ export default function VNCApp() {
const handleReconnect = useCallback(() => {
setStatus("connecting");
setError(null);
setAuthExpired(false);
setVncInfo(null);
checkVnc();
}, [checkVnc]);
Expand All @@ -405,9 +427,15 @@ export default function VNCApp() {
try {
const res = await fetch("/setup-api/install/run-step", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...JSON_ACCEPT_HEADERS },
body: JSON.stringify({ step: "vnc_install" }),
});
if (isAuthExpired(res)) {
setAuthExpired(true);
setRepairError(SESSION_EXPIRED_MESSAGE);
setRepairState("failed");
return;
}
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
setRepairError(data?.error || `vnc_install failed (HTTP ${res.status})`);
Expand All @@ -422,9 +450,15 @@ export default function VNCApp() {
try {
const rebootRes = await fetch("/setup-api/system/power", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...JSON_ACCEPT_HEADERS },
body: JSON.stringify({ action: "restart" }),
});
if (isAuthExpired(rebootRes)) {
setAuthExpired(true);
setRepairError(SESSION_EXPIRED_MESSAGE);
setRepairState("failed");
return;
}
if (!rebootRes.ok) {
const body = await rebootRes.json().catch(() => ({}));
setRepairError(body?.error || `Reboot request failed (HTTP ${rebootRes.status})`);
Expand All @@ -447,7 +481,15 @@ export default function VNCApp() {
while (Date.now() - start < TIMEOUT_MS) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
try {
const probe = await fetch("/setup-api/vnc", { cache: "no-store" });
const probe = await fetch("/setup-api/vnc", { cache: "no-store", headers: JSON_ACCEPT_HEADERS });
if (isAuthExpired(probe)) {
// Session expired mid-reboot — stop polling and prompt re-login
// rather than spinning to the 5-minute timeout.
setAuthExpired(true);
setRepairError(SESSION_EXPIRED_MESSAGE);
setRepairState("failed");
return;
}
if (!probe.ok) continue;
const data = await probe.json().catch(() => ({}));
if (data?.available) {
Expand Down Expand Up @@ -496,9 +538,14 @@ export default function VNCApp() {
try {
const res = await fetch("/setup-api/vnc/clipboard", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...JSON_ACCEPT_HEADERS },
body: JSON.stringify({ text }),
});
if (isAuthExpired(res)) {
setAuthExpired(true);
setPasteError(SESSION_EXPIRED_MESSAGE);
return false;
}
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
setPasteError(body.error || `Clipboard write failed (HTTP ${res.status})`);
Expand Down Expand Up @@ -556,7 +603,9 @@ export default function VNCApp() {
try {
let text = "";
try {
const res = await fetch("/setup-api/vnc/clipboard", { cache: "no-store" });
const res = await fetch("/setup-api/vnc/clipboard", { cache: "no-store", headers: JSON_ACCEPT_HEADERS });
// Session expired — never parse a login page as clipboard JSON.
if (isAuthExpired(res)) { setAuthExpired(true); return; }
if (!res.ok) return;
const body = (await res.json().catch(() => ({}))) as { text?: string };
text = typeof body.text === "string" ? body.text : "";
Expand Down Expand Up @@ -642,6 +691,16 @@ export default function VNCApp() {
<span className="material-symbols-rounded animate-spin text-orange-400" style={{ fontSize: 28 }}>progress_activity</span>
<p className="text-sm text-white/80">Installing / repairing Remote Desktop… this may take a few minutes.</p>
</div>
) : authExpired ? (
<div className="flex flex-wrap items-center justify-center gap-2 mt-2">
<button
onClick={() => window.location.assign("/login")}
className="px-4 py-2 btn-gradient rounded-lg text-sm text-white transition-colors cursor-pointer flex items-center gap-2"
>
<span className="material-symbols-rounded" style={{ fontSize: 16 }}>login</span>
Sign in again
</button>
</div>
) : (
<div className="flex flex-wrap items-center justify-center gap-2 mt-2">
<button
Expand Down
26 changes: 26 additions & 0 deletions src/lib/setup-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Client-side detection of an expired/absent setup session.
//
// When the session cookie is missing or invalid, src/middleware.ts responds to
// a /setup-api/* request in one of two ways (see middleware.ts:250-258):
// - request accepts JSON -> 401 { error: "Authentication required" }
// - otherwise -> 307 redirect to /login
// `fetch` transparently follows the redirect, so a caller that forgets the
// Accept header ends up with a 200 HTML login page — which blows up any
// `res.json()`. Send JSON_ACCEPT_HEADERS to get the clean 401, and use
// isAuthExpired() to catch both shapes before parsing a body.

export const JSON_ACCEPT_HEADERS = { Accept: "application/json" } as const;

export function isAuthExpired(res: Response): boolean {
if (res.status === 401) return true;
// A followed redirect whose final URL is the login page also means the
// session expired (the HTML-client path).
if (res.redirected) {
try {
return new URL(res.url).pathname === "/login";
} catch {
return false;
}
}
return false;
}
33 changes: 33 additions & 0 deletions src/tests/unit/setup-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { isAuthExpired, JSON_ACCEPT_HEADERS } from "@/lib/setup-auth";

// Minimal Response-like stub — isAuthExpired only reads status/redirected/url.
function res({ status = 200, redirected = false, url = "http://box.local/setup-api/vnc" } = {}): Response {
return { status, redirected, url } as Response;
}

describe("isAuthExpired", () => {
it("flags a 401 (the JSON-client path from middleware)", () => {
expect(isAuthExpired(res({ status: 401 }))).toBe(true);
});

it("flags a followed redirect whose final path is /login (the HTML-client path)", () => {
expect(isAuthExpired(res({ status: 200, redirected: true, url: "http://box.local/login?redirect=/setup-api/vnc" }))).toBe(true);
});

it("treats a normal 200 JSON response as an active session", () => {
expect(isAuthExpired(res({ status: 200 }))).toBe(false);
});

it("ignores a redirect that is not to /login", () => {
expect(isAuthExpired(res({ status: 200, redirected: true, url: "http://box.local/setup-api/vnc" }))).toBe(false);
});

it("does not treat other error statuses as auth expiry", () => {
expect(isAuthExpired(res({ status: 500 }))).toBe(false);
});

it("requests JSON so middleware answers 401 instead of a login redirect", () => {
expect(JSON_ACCEPT_HEADERS.Accept).toBe("application/json");
});
});
Loading