diff --git a/apps/web/src/lib/api-client.test.ts b/apps/web/src/lib/api-client.test.ts index 312b274..d4d0031 100644 --- a/apps/web/src/lib/api-client.test.ts +++ b/apps/web/src/lib/api-client.test.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useUserStore } from '@/stores/user'; import { apiClient, HttpError } from './api-client'; function setNavigatorOnline(value: boolean): void { @@ -103,4 +104,52 @@ describe('apiClient error handling', () => { await expect(request).resolves.toEqual({ ok: true }); expect(fetchMock).toHaveBeenCalledWith('/api/queued-write', expect.objectContaining({ method: 'POST' })); }); + + it('injects Authorization Bearer from the admin session token', async () => { + useUserStore.getState().setAdminSession({ + username: 'admin', + accessToken: 'jwt-token', + tokenType: 'bearer', + expiresIn: 3600, + }); + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + await expect(apiClient.get('/admin/orders', z.object({ ok: z.boolean() }))).resolves.toEqual({ ok: true }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const calls = fetchMock.mock.calls as Array<[string, RequestInit]>; + expect(calls[0][0]).toBe('/api/admin/orders'); + const headers = calls[0][1].headers as Record; + expect(headers.Authorization).toBe('Bearer jwt-token'); + }); + + it('does not inject Authorization after the admin token has expired', async () => { + useUserStore.getState().setAdminSession({ + username: 'admin', + accessToken: 'expired-token', + tokenType: 'bearer', + expiresIn: -1, + }); + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + await expect(apiClient.get('/admin/orders', z.object({ ok: z.boolean() }))).resolves.toEqual({ ok: true }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const calls = fetchMock.mock.calls as Array<[string, RequestInit]>; + expect(calls[0][0]).toBe('/api/admin/orders'); + const headers = calls[0][1].headers as Record; + expect(headers.Authorization).toBeUndefined(); + }); }); diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 78e9d1d..cbf65c1 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -7,6 +7,7 @@ * - Error codes map to i18n copy. */ import { ZodError, type ZodType, type ZodTypeDef } from 'zod'; +import { useUserStore } from '@/stores/user'; import { getLocalizedApiErrorMessage, type ApiErrorSeverity } from './error-messages'; export interface ApiError extends Error { @@ -61,6 +62,15 @@ function isWriteMethod(method: RequestOptions['method']): boolean { return method !== undefined && method !== 'GET'; } +function getActiveAuthorizationHeader(): string | undefined { + const { token, tokenType, tokenExpiresAt } = useUserStore.getState(); + if (!token || !tokenExpiresAt || tokenExpiresAt <= Date.now()) { + return undefined; + } + const scheme = (tokenType ?? 'bearer').toLowerCase() === 'bearer' ? 'Bearer' : tokenType; + return `${scheme} ${token}`; +} + function waitUntilOnline(signal: AbortSignal | undefined): Promise { if (!isBrowserOffline() || typeof window === 'undefined') { return Promise.resolve(); @@ -98,11 +108,13 @@ async function request( ): Promise { const { method = 'GET', body, signal, headers = {} } = options; + const authHeader = getActiveAuthorizationHeader(); const init: RequestInit = { method, headers: { 'Content-Type': 'application/json', Accept: 'application/json', + ...(authHeader ? { Authorization: authHeader } : {}), ...headers, }, signal,