Skip to content

Commit e623c22

Browse files
dorlugasigalCopilot
andcommitted
fix(auth): prevent white page in no-password mode after idle
Add /api/config endpoint so frontend knows when auth is disabled. In no-password mode, skip auth checks and show reconnecting UI instead of a dead-end login page when tunnel is stale. Add CacheableResponsePlugin to service worker to prevent caching non-200 responses from DevTunnel. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 24f0f52 commit e623c22

9 files changed

Lines changed: 234 additions & 13 deletions

File tree

docs/api.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ TermBeam exposes a REST API and WebSocket interface.
44

55
## REST API
66

7-
All API endpoints (except `/login`, `/api/auth`, and `/api/version`) require authentication via cookie or Bearer token.
7+
All API endpoints (except `/login`, `/api/auth`, `/api/version`, and `/api/config`) require authentication via cookie or Bearer token.
88

99
<!-- prettier-ignore -->
1010
!!! note
@@ -281,6 +281,20 @@ Get the server version.
281281
{ "version": "1.0.0" }
282282
```
283283

284+
#### `GET /api/config`
285+
286+
Get public server configuration. No authentication required.
287+
288+
**Response:**
289+
290+
```json
291+
{ "passwordRequired": true }
292+
```
293+
294+
| Field | Type | Description |
295+
| ------------------ | ------- | ---------------------------------------------- |
296+
| `passwordRequired` | boolean | Whether the server requires password to access |
297+
284298
#### `GET /api/update-check`
285299

286300
Check if a newer version of TermBeam is available on npm. Results are cached for 24 hours.

src/frontend/package-lock.json

Lines changed: 1 addition & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"react": "^19.0.0",
2727
"react-dom": "^19.0.0",
2828
"sonner": "^2.0.3",
29+
"workbox-cacheable-response": "^7.4.0",
2930
"zustand": "^5.0.3"
3031
},
3132
"overrides": {

src/frontend/src/App.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ function normalizeSessionParam() {
2121
}
2222

2323
export default function App() {
24-
const { authenticated, login, loading } = useAuth();
24+
const { authenticated, passwordRequired, login, loading } = useAuth();
2525
const [path, setPath] = useState(getPath);
2626

2727
useEffect(() => {
@@ -53,6 +53,43 @@ export default function App() {
5353
}
5454

5555
if (!authenticated) {
56+
// No-password mode: server is unreachable — show reconnecting UI instead of login
57+
if (!passwordRequired) {
58+
return (
59+
<div
60+
style={{
61+
display: 'flex',
62+
flexDirection: 'column',
63+
alignItems: 'center',
64+
justifyContent: 'center',
65+
height: '100vh',
66+
gap: '16px',
67+
background: 'var(--bg)',
68+
color: 'var(--text)',
69+
}}
70+
>
71+
<div className="spinner" />
72+
<p style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>
73+
Reconnecting to server…
74+
</p>
75+
<button
76+
onClick={() => window.location.reload()}
77+
style={{
78+
marginTop: '8px',
79+
padding: '8px 20px',
80+
background: 'var(--accent)',
81+
color: '#fff',
82+
border: 'none',
83+
borderRadius: '6px',
84+
cursor: 'pointer',
85+
fontSize: '13px',
86+
}}
87+
>
88+
Retry
89+
</button>
90+
</div>
91+
);
92+
}
5693
return <LoginPage onLogin={login} loading={loading} />;
5794
}
5895

src/frontend/src/hooks/useAuth.ts

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,34 @@
11
import { useState, useEffect, useCallback } from 'react';
2-
import { checkAuth, login as apiLogin, logout as apiLogout } from '@/services/api';
2+
import { checkAuth, getConfig, login as apiLogin, logout as apiLogout } from '@/services/api';
33

44
interface UseAuthReturn {
55
authenticated: boolean | null;
6+
passwordRequired: boolean;
67
login: (password: string) => Promise<boolean>;
78
logout: () => Promise<void>;
89
loading: boolean;
910
}
1011

1112
export function useAuth(): UseAuthReturn {
1213
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
14+
const [passwordRequired, setPasswordRequired] = useState(true);
1315
const [loading, setLoading] = useState(false);
1416

1517
useEffect(() => {
1618
let cancelled = false;
1719

1820
async function init() {
21+
// Determine if password is required (uses localStorage cache when server unreachable)
22+
const config = await getConfig();
23+
if (cancelled) return;
24+
setPasswordRequired(config.passwordRequired);
25+
26+
// No-password mode: skip auth checks entirely — always grant access
27+
if (!config.passwordRequired) {
28+
setAuthenticated(true);
29+
return;
30+
}
31+
1932
// Check for one-time-token in URL
2033
const params = new URLSearchParams(window.location.search);
2134
const ott = params.get('ott');
@@ -50,8 +63,6 @@ export function useAuth(): UseAuthReturn {
5063
const { authenticated: isAuth, serverReachable } = await checkAuth();
5164
if (!cancelled) {
5265
if (!isAuth && !serverReachable) {
53-
// Server unreachable — show login page so user sees something
54-
// (a full reload could loop if the server is genuinely down)
5566
setAuthenticated(false);
5667
return;
5768
}
@@ -69,17 +80,41 @@ export function useAuth(): UseAuthReturn {
6980
}, []);
7081

7182
// Re-check auth when returning from background (e.g. mobile tab switch after hours idle).
72-
// Catches expired tokens / stale DevTunnel sessions that would otherwise show a white screen.
7383
useEffect(() => {
84+
let retryTimer: ReturnType<typeof setTimeout> | null = null;
85+
7486
function handleVisibility() {
75-
if (document.hidden || authenticated !== true) return;
87+
if (document.hidden) return;
88+
89+
if (!passwordRequired) {
90+
// No-password mode: server is always "authenticated", but verify reachability.
91+
// If unreachable, keep authenticated=true — the terminal/sessions hub will
92+
// show its own connection banner. Once reachable again, everything auto-recovers.
93+
checkAuth().then(({ serverReachable }) => {
94+
if (!serverReachable && retryTimer === null) {
95+
// Schedule a silent retry in case tunnel just needs a moment
96+
retryTimer = setTimeout(() => {
97+
retryTimer = null;
98+
checkAuth(); // fire-and-forget; UI stays on terminal
99+
}, 5000);
100+
}
101+
});
102+
return;
103+
}
104+
105+
// Password mode: if we were authenticated and now we're not, show login
106+
if (authenticated !== true) return;
76107
checkAuth().then(({ authenticated: isAuth }) => {
77108
if (!isAuth) setAuthenticated(false);
78109
});
79110
}
111+
80112
document.addEventListener('visibilitychange', handleVisibility);
81-
return () => document.removeEventListener('visibilitychange', handleVisibility);
82-
}, [authenticated]);
113+
return () => {
114+
document.removeEventListener('visibilitychange', handleVisibility);
115+
if (retryTimer !== null) clearTimeout(retryTimer);
116+
};
117+
}, [authenticated, passwordRequired]);
83118

84119
const login = useCallback(async (password: string): Promise<boolean> => {
85120
setLoading(true);
@@ -88,7 +123,6 @@ export function useAuth(): UseAuthReturn {
88123
setAuthenticated(ok);
89124
return ok;
90125
} catch (err) {
91-
// Re-throw so caller can distinguish 429 from other errors
92126
throw err;
93127
} finally {
94128
setLoading(false);
@@ -100,5 +134,5 @@ export function useAuth(): UseAuthReturn {
100134
setAuthenticated(false);
101135
}, []);
102136

103-
return { authenticated, login, logout, loading };
137+
return { authenticated, passwordRequired, login, logout, loading };
104138
}

src/frontend/src/services/api.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,25 @@ export async function checkAuth(): Promise<{
181181
}
182182
}
183183

184+
export async function getConfig(): Promise<{ passwordRequired: boolean }> {
185+
try {
186+
const res = await fetchWithTimeout(`${BASE}/api/config`, { credentials: 'same-origin' });
187+
const ct = res.headers.get('content-type') || '';
188+
if (!ct.includes('application/json')) {
189+
// Server unreachable or tunnel returning HTML — use cached value
190+
const cached = localStorage.getItem('tb:passwordRequired');
191+
return { passwordRequired: cached !== 'false' };
192+
}
193+
const data = (await res.json()) as { passwordRequired: boolean };
194+
localStorage.setItem('tb:passwordRequired', String(data.passwordRequired));
195+
return data;
196+
} catch {
197+
// Network error — fall back to cached value
198+
const cached = localStorage.getItem('tb:passwordRequired');
199+
return { passwordRequired: cached !== 'false' };
200+
}
201+
}
202+
184203
export async function login(password: string): Promise<{ ok: boolean }> {
185204
const res = await fetchWithTimeout(`${BASE}/api/auth`, {
186205
method: 'POST',

src/frontend/src/sw.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
33
import { registerRoute, NavigationRoute } from 'workbox-routing';
44
import { CacheFirst, NetworkFirst, NetworkOnly } from 'workbox-strategies';
55
import { ExpirationPlugin } from 'workbox-expiration';
6+
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
67

78
declare let self: ServiceWorkerGlobalScope;
89

@@ -13,11 +14,14 @@ cleanupOutdatedCaches();
1314
// Navigation requests (HTML documents) use NetworkFirst so that external auth
1415
// redirects (e.g. DevTunnel Microsoft login) pass through to the browser
1516
// instead of being short-circuited by the precache.
17+
// CacheableResponsePlugin ensures only 200 OK responses are cached — prevents
18+
// stale DevTunnel auth pages or error HTML from polluting the navigation cache.
1619
registerRoute(
1720
new NavigationRoute(
1821
new NetworkFirst({
1922
cacheName: 'termbeam-navigation',
2023
networkTimeoutSeconds: 5,
24+
plugins: [new CacheableResponsePlugin({ statuses: [200] })],
2125
}),
2226
),
2327
);

src/server/routes.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ function setupRoutes(app, { auth, sessions, config, state }) {
8888
res.json({ version: getVersion() });
8989
});
9090

91+
// Public config — no auth required
92+
app.get('/api/config', (_req, res) => {
93+
res.json({ passwordRequired: !!auth.password });
94+
});
95+
9196
// Update check API
9297
app.get('/api/update-check', apiRateLimit, auth.middleware, async (req, res) => {
9398
log.debug('Update check requested');

test/server/config.test.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
const { describe, it, after } = require('node:test');
2+
const assert = require('node:assert');
3+
const http = require('http');
4+
const { createTermBeamServer } = require('../../src/server');
5+
6+
// --- Helpers ---
7+
8+
const baseConfig = {
9+
port: 0,
10+
host: '127.0.0.1',
11+
password: null,
12+
useTunnel: false,
13+
persistedTunnel: false,
14+
shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/sh',
15+
shellArgs: [],
16+
cwd: process.cwd(),
17+
defaultShell: process.platform === 'win32' ? 'cmd.exe' : '/bin/sh',
18+
version: '0.1.0-test',
19+
logLevel: 'error',
20+
};
21+
22+
function makeConfig(overrides = {}) {
23+
return { ...baseConfig, ...overrides };
24+
}
25+
26+
function httpRequest(options) {
27+
return new Promise((resolve, reject) => {
28+
const req = http.request(options, (res) => {
29+
const chunks = [];
30+
res.on('data', (chunk) => chunks.push(chunk));
31+
res.on('end', () =>
32+
resolve({
33+
statusCode: res.statusCode,
34+
headers: res.headers,
35+
data: Buffer.concat(chunks).toString(),
36+
}),
37+
);
38+
});
39+
req.on('error', reject);
40+
req.end();
41+
});
42+
}
43+
44+
async function startServer(configOverrides = {}) {
45+
const instance = createTermBeamServer({ config: makeConfig(configOverrides) });
46+
await instance.start();
47+
const port = instance.server.address().port;
48+
return { ...instance, port };
49+
}
50+
51+
// --- Tests ---
52+
53+
describe('GET /api/config', () => {
54+
describe('with password set', () => {
55+
let inst;
56+
after(() => inst?.shutdown());
57+
58+
it('returns passwordRequired: true', async () => {
59+
inst = await startServer({ password: 'testpass' });
60+
const res = await httpRequest({
61+
hostname: '127.0.0.1',
62+
port: inst.port,
63+
path: '/api/config',
64+
method: 'GET',
65+
});
66+
assert.strictEqual(res.statusCode, 200);
67+
const body = JSON.parse(res.data);
68+
assert.deepStrictEqual(body, { passwordRequired: true });
69+
});
70+
});
71+
72+
describe('with no password', () => {
73+
let inst;
74+
after(() => inst?.shutdown());
75+
76+
it('returns passwordRequired: false', async () => {
77+
inst = await startServer({ password: null });
78+
const res = await httpRequest({
79+
hostname: '127.0.0.1',
80+
port: inst.port,
81+
path: '/api/config',
82+
method: 'GET',
83+
});
84+
assert.strictEqual(res.statusCode, 200);
85+
const body = JSON.parse(res.data);
86+
assert.deepStrictEqual(body, { passwordRequired: false });
87+
});
88+
});
89+
90+
describe('does not require authentication', () => {
91+
let inst;
92+
after(() => inst?.shutdown());
93+
94+
it('returns 200 without any auth cookie or token', async () => {
95+
inst = await startServer({ password: 'secretpass' });
96+
// No cookie or authorization header — should still succeed
97+
const res = await httpRequest({
98+
hostname: '127.0.0.1',
99+
port: inst.port,
100+
path: '/api/config',
101+
method: 'GET',
102+
});
103+
assert.strictEqual(res.statusCode, 200);
104+
const body = JSON.parse(res.data);
105+
assert.strictEqual(body.passwordRequired, true);
106+
});
107+
});
108+
});

0 commit comments

Comments
 (0)