Skip to content

Commit daecf09

Browse files
CoderCococlaude
andauthored
chore(web): remove ApiTokenModal and localStorage token handling (#240)
Closes #162 ## Summary - Deletes `ApiTokenModal` component and its test suite — no longer needed now that the renderer communicates over IPC instead of HTTP (no bearer token required) - Removes `apiToken` localStorage read/write, the 401-retry queue, and all `Authorization: Bearer` header plumbing from `api.service.ts` and `app.component.tsx` - Cleans up `AuthGatePage` page-object, `auth-gate.spec.ts` e2e spec, and the `API_TOKEN` env var reference from the integration test server config ## Changes ``` app/packages/web/e2e/fixtures/gsd-http-bridge.ts 14 changes remove auth header injection app/packages/web/e2e/fixtures/index.ts 26 deletions remove authedPage / token fixtures app/packages/web/e2e/fixtures/server-mocks.ts 9 changes drop token seeding app/packages/web/e2e/pages/AuthGatePage.ts 45 deletions delete auth-gate page object app/packages/web/e2e/pages/index.ts 1 deletion remove AuthGatePage export app/packages/web/e2e/specs/auth-gate.spec.ts 26 deletions delete auth-gate e2e spec app/packages/web/playwright.integration.config.ts 1 deletion remove API_TOKEN env var app/packages/web/src/api.service.test.ts 72 changes drop token tests app/packages/web/src/api.service.ts 45 deletions remove auth header + 401 retry app/packages/web/src/app.component.tsx 18 changes remove ApiTokenModal wiring app/packages/web/src/components/api-token-modal.component.test.tsx 153 deletions delete component tests app/packages/web/src/components/api-token-modal.component.tsx 166 deletions delete component ``` ## Test plan - [ ] `npm run app:test` — all unit tests pass (`api-token-modal.component.test.tsx` deleted, remaining web specs pass) - [ ] `npm run app:lint` — 0 errors - [ ] `apiToken` is not referenced anywhere in `app/packages/web/` (`grep -r apiToken app/packages/web/src` returns nothing) - [ ] `ApiTokenModal` component and its test file no longer exist in the repository - [ ] `app.component.tsx` renders without any token-modal state or localStorage reads - [ ] `api.service.ts` contains no `Authorization` header construction or 401-retry logic - [ ] E2E suite passes without auth-gate specs (`npm run app:test:e2e`) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 907780c commit daecf09

12 files changed

Lines changed: 15 additions & 561 deletions

app/packages/web/e2e/fixtures/gsd-http-bridge.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,12 @@
1919
*
2020
* It is intentionally self-contained — it closes over no module-scope bindings —
2121
* because Playwright serialises it to source and re-evaluates it in the page.
22-
* The bearer token is read from `localStorage` on every call, mirroring the
23-
* old `fetchWithAuth`, so it picks up whatever the `authedPage` fixture seeded.
22+
* The Nest API no longer requires a bearer token, so calls go out with only the
23+
* headers each request supplies.
2424
*/
2525
export function installGsdHttpBridge(): void {
26-
const authHeader = (): Record<string, string> => {
27-
const token = localStorage.getItem('apiToken') ?? '';
28-
return token ? { Authorization: `Bearer ${token}` } : {};
29-
};
30-
3126
const call = async (path: string, init?: RequestInit): Promise<unknown> => {
32-
const res = await fetch(path, {
33-
...init,
34-
headers: { ...(init?.headers as Record<string, string> | undefined), ...authHeader() },
35-
});
27+
const res = await fetch(path, init);
3628
if (!res.ok) throw new Error(`API error ${res.status}`);
3729
return res.json();
3830
};

app/packages/web/e2e/fixtures/index.ts

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
CONFIGURED_DISCORD_CONFIG,
1717
makeActualCosts,
1818
} from './game-data.js';
19-
import { AppLayout, AuthGatePage, DashboardPage, CostsPage, LogsPage, SettingsPage } from '../pages/index.js';
19+
import { AppLayout, DashboardPage, CostsPage, LogsPage, SettingsPage } from '../pages/index.js';
2020
import { installGsdHttpBridge } from './gsd-http-bridge.js';
2121

2222
export type {
@@ -44,7 +44,7 @@ export {
4444
VALID_USER_ID,
4545
SAMPLE_LOG_LINES,
4646
} from './game-data.js';
47-
export { AppLayout, AuthGatePage, DashboardPage, CostsPage, LogsPage, SettingsPage } from '../pages/index.js';
47+
export { AppLayout, DashboardPage, CostsPage, LogsPage, SettingsPage } from '../pages/index.js';
4848

4949
/** Per-spec overrides for the default `/api/*` stubs registered by `stubApis`. */
5050
export interface StubOptions {
@@ -218,10 +218,10 @@ export async function stubApis(page: Page, opts: StubOptions = {}): Promise<void
218218

219219
type E2EFixtures = {
220220
/**
221-
* A page with `apiToken` pre-seeded in localStorage so every navigation
222-
* starts authenticated. Use this in specs that need raw page access (e.g.
223-
* to call `stubApis` or `addInitScript`); prefer `dashboard` / `costs` /
224-
* `layout` for higher-level interactions.
221+
* Raw page handle for specs that need direct page access (e.g. to call
222+
* `stubApis` or `addInitScript`); prefer `dashboard` / `costs` / `layout`
223+
* for higher-level interactions. Retained as a named alias now that the API
224+
* no longer requires a pre-seeded token.
225225
*/
226226
authedPage: Page;
227227
/** Page object for the dashboard route — use in any authed-dashboard spec. */
@@ -234,21 +234,14 @@ type E2EFixtures = {
234234
settings: SettingsPage;
235235
/** Page object for the persistent nav shell (sidebar + top bar). */
236236
layout: AppLayout;
237-
/** Page object for the API-token modal — use in auth-gate specs. */
238-
authGate: AuthGatePage;
239237
};
240238

241239
export const test = base.extend<E2EFixtures>({
242240
authedPage: async ({ page }, use) => {
243-
await page.addInitScript(() => {
244-
localStorage.setItem('apiToken', 'test-token');
245-
});
246241
await use(page);
247242
},
248-
// `dashboard` and `costs` depend on `authedPage` because every authed-route
249-
// spec wants the token pre-seeded. `layout` and `authGate` depend on the raw
250-
// `page` so auth-gate specs (which exercise the unauthenticated state) can
251-
// use them without dragging the init script along.
243+
// `dashboard` and `costs` resolve through `authedPage`; `layout` uses the raw
244+
// `page`. Both are plain page handles now that no token seeding is required.
252245
dashboard: async ({ authedPage }, use) => {
253246
await use(new DashboardPage(authedPage));
254247
},
@@ -264,9 +257,6 @@ export const test = base.extend<E2EFixtures>({
264257
layout: async ({ page }, use) => {
265258
await use(new AppLayout(page));
266259
},
267-
authGate: async ({ page }, use) => {
268-
await use(new AuthGatePage(page));
269-
},
270260
});
271261

272262
export { expect } from '@playwright/test';

app/packages/web/e2e/fixtures/server-mocks.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { DashboardPage } from '../pages/DashboardPage.js';
44
import { installGsdHttpBridge } from './gsd-http-bridge.js';
55

66
const SERVER_BASE = 'http://localhost:3002';
7-
const AUTH_HEADERS = { Authorization: 'Bearer test-token' };
87

98
/** Shape matching the server-side MockResponse — one queued ECS command reply. */
109
export interface MockResponse {
@@ -25,7 +24,6 @@ export class ServerMocks {
2524

2625
private async post(path: string, body?: unknown): Promise<void> {
2726
const res = await this.request.post(`${SERVER_BASE}${path}`, {
28-
headers: AUTH_HEADERS,
2927
...(body !== undefined ? { data: body } : {}),
3028
});
3129
if (!res.ok()) throw new Error(`Mock control call failed ${res.status()} ${path}`);
@@ -54,8 +52,8 @@ type IntegrationFixtures = {
5452
*/
5553
serverMocks: ServerMocks;
5654
/**
57-
* Page with `apiToken = 'test-token'` pre-seeded in localStorage so every
58-
* navigation to the Vite preview starts authenticated.
55+
* Page with the `window.gsd` HTTP bridge installed so every navigation to the
56+
* Vite preview can reach the real Nest server on :3002.
5957
*/
6058
authedPage: Page;
6159
/** Dashboard page object backed by `authedPage`. */
@@ -71,9 +69,6 @@ export const test = base.extend<IntegrationFixtures>({
7169
},
7270

7371
authedPage: async ({ page }, use) => {
74-
await page.addInitScript(() => {
75-
localStorage.setItem('apiToken', 'test-token');
76-
});
7772
// The web client talks to `window.gsd.*`; install a browser-side bridge
7873
// that forwards each IPC call to the matching `/api/*` route, which the
7974
// integration preview proxies to the real Nest server on :3002.

app/packages/web/e2e/pages/AuthGatePage.ts

Lines changed: 0 additions & 45 deletions
This file was deleted.

app/packages/web/e2e/pages/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
export { AppLayout } from './AppLayout.js';
2-
export { AuthGatePage } from './AuthGatePage.js';
32
export { DashboardPage, type ServerStateLabel } from './DashboardPage.js';
43
export { CostsPage, type CostsRangeLabel } from './CostsPage.js';
54
export { LogsPage, type LogLevelLabel } from './LogsPage.js';

app/packages/web/e2e/specs/auth-gate.spec.ts

Lines changed: 0 additions & 26 deletions
This file was deleted.

app/packages/web/playwright.integration.config.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ export default defineConfig({
4242
env: {
4343
PORT: '3002',
4444
NODE_ENV: 'test',
45-
API_TOKEN: 'test-token',
4645
TF_STATE_PATH: tfstatePath,
4746
},
4847
},

app/packages/web/src/api.service.test.ts

Lines changed: 1 addition & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,5 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2-
import {
3-
getStoredApiToken,
4-
setStoredApiToken,
5-
setUnauthorizedHandler,
6-
retryPendingAfterAuth,
7-
api,
8-
} from './api.service.js';
9-
10-
// jsdom provides localStorage, but we replace it with a controlled stub so
11-
// tests are isolated from each other's stored tokens.
12-
const localStorageMock = (() => {
13-
let store: Record<string, string> = {};
14-
return {
15-
getItem: vi.fn((key: string) => store[key] ?? null),
16-
setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
17-
removeItem: vi.fn((key: string) => { delete store[key]; }),
18-
clear: () => { store = {}; },
19-
};
20-
})();
2+
import { api } from './api.service.js';
213

224
/**
235
* Builds a fresh `window.gsd` IPC-bridge double. Every namespace method is a
@@ -77,56 +59,15 @@ function makeGsdMock() {
7759
let gsd: ReturnType<typeof makeGsdMock>;
7860

7961
beforeEach(() => {
80-
localStorageMock.clear();
81-
localStorageMock.getItem.mockClear();
82-
localStorageMock.setItem.mockClear();
83-
localStorageMock.removeItem.mockClear();
84-
vi.stubGlobal('localStorage', localStorageMock);
8562
gsd = makeGsdMock();
8663
vi.stubGlobal('gsd', gsd);
87-
setStoredApiToken('');
88-
setUnauthorizedHandler(null);
8964
});
9065

9166
afterEach(() => {
9267
vi.unstubAllGlobals();
9368
vi.restoreAllMocks();
9469
});
9570

96-
describe('getStoredApiToken / setStoredApiToken', () => {
97-
it('should return an empty string when no token has been stored', () => {
98-
expect(getStoredApiToken()).toBe('');
99-
});
100-
101-
it('should persist and retrieve a non-empty token', () => {
102-
setStoredApiToken('my-api-token');
103-
expect(getStoredApiToken()).toBe('my-api-token');
104-
});
105-
106-
it('should remove the stored token when called with an empty string', () => {
107-
setStoredApiToken('tok');
108-
setStoredApiToken('');
109-
expect(getStoredApiToken()).toBe('');
110-
});
111-
112-
it('should return empty string when localStorage.getItem throws', () => {
113-
vi.stubGlobal('localStorage', {
114-
getItem: () => { throw new Error('unavailable'); },
115-
});
116-
expect(getStoredApiToken()).toBe('');
117-
});
118-
119-
it('should silently ignore setItem errors (e.g. private browsing quota)', () => {
120-
vi.stubGlobal('localStorage', {
121-
getItem: () => null,
122-
setItem: () => { throw new Error('QuotaExceeded'); },
123-
removeItem: () => { throw new Error('unavailable'); },
124-
});
125-
expect(() => setStoredApiToken('tok')).not.toThrow();
126-
expect(() => setStoredApiToken('')).not.toThrow();
127-
});
128-
});
129-
13071
describe('IPC bridge delegation', () => {
13172
it('should delegate api.env() to window.gsd.env.get()', async () => {
13273
await api.env();
@@ -264,14 +205,3 @@ describe('missing IPC bridge', () => {
264205
await expect(api.env()).rejects.toThrow('window.gsd IPC bridge is unavailable');
265206
});
266207
});
267-
268-
describe('inert auth stubs (retained for #162)', () => {
269-
it('should treat setUnauthorizedHandler as a no-op that never throws', () => {
270-
expect(() => setUnauthorizedHandler(vi.fn())).not.toThrow();
271-
expect(() => setUnauthorizedHandler(null)).not.toThrow();
272-
});
273-
274-
it('should resolve retryPendingAfterAuth to true since nothing is ever queued', async () => {
275-
expect(await retryPendingAfterAuth()).toBe(true);
276-
});
277-
});

app/packages/web/src/api.service.ts

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -110,51 +110,6 @@ export interface EnvInfo {
110110
environment: string;
111111
}
112112

113-
const TOKEN_STORAGE_KEY = 'apiToken';
114-
115-
/** Read the stored API bearer token from localStorage (returns empty string if unset). */
116-
export function getStoredApiToken(): string {
117-
try {
118-
return localStorage.getItem(TOKEN_STORAGE_KEY) ?? '';
119-
} catch {
120-
return '';
121-
}
122-
}
123-
124-
/** Persist the API bearer token for subsequent requests. Clear with `''`. */
125-
export function setStoredApiToken(token: string): void {
126-
try {
127-
if (token) localStorage.setItem(TOKEN_STORAGE_KEY, token);
128-
else localStorage.removeItem(TOKEN_STORAGE_KEY);
129-
} catch {
130-
// localStorage unavailable (private mode, etc.); failures are non-fatal — user will just be re-prompted.
131-
}
132-
}
133-
134-
/**
135-
* Retained for source compatibility while the renderer finishes migrating off
136-
* the old HTTP/bearer transport. The 401-retry queue these drove only made
137-
* sense for `fetch`; IPC has no per-request 401, so both are now inert no-ops.
138-
* They stay exported because `app.component.tsx` and `api-token-modal.component.tsx`
139-
* still import them — those call sites are removed in #162.
140-
*
141-
* @param _handler - Ignored. Kept so existing callers still type-check.
142-
*/
143-
export function setUnauthorizedHandler(_handler: (() => void) | null): void {
144-
// no-op: there is no HTTP 401 to intercept over the IPC bridge.
145-
}
146-
147-
/**
148-
* Inert counterpart to {@link setUnauthorizedHandler}: nothing is ever queued
149-
* now, so this resolves to `true` (every — i.e. zero — parked request
150-
* "succeeded") and any caller proceeds as before.
151-
*
152-
* @returns A promise that always resolves to `true`.
153-
*/
154-
export function retryPendingAfterAuth(): Promise<boolean> {
155-
return Promise.resolve(true);
156-
}
157-
158113
/**
159114
* Returns the `window.gsd` IPC bridge, throwing a descriptive error if it is
160115
* absent. The bridge is injected by the Electron preload script, so a missing

0 commit comments

Comments
 (0)