feat(tracker-web): dynamic, owner-scoped project switcher via /products/mine
Wires the switcher to real per-user data instead of a static list: - New /api/products/[...path] proxy to platform-service (mirrors the fleet proxy), exposing GET /api/products/mine. - ProductProvider fetches the caller's owner-scoped projects on mount (when a tracker_token is present) and uses them as the switcher list. Best-effort: any failure / empty result / unauthenticated state keeps the configured fallback list, so dev and logged-out rendering still work. Combined with the earlier config-driven list + auto-hide, the switcher now reflects the authenticated user's projects on a generic platform. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
42c63dcc6e
commit
acf7c36cda
@ -1,6 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it, beforeAll, beforeEach, vi } from 'vitest';
|
||||
import { describe, expect, it, beforeAll, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { act, createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
@ -14,6 +14,10 @@ beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderProductHarness() {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
@ -73,4 +77,51 @@ describe('ProductProvider', () => {
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('does not fetch /products/mine when there is no auth token (keeps the default list)', () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
const { cleanup } = renderProductHarness();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('replaces the list with the caller\u2019s projects from /products/mine when authed', async () => {
|
||||
localStorage.setItem('tracker_token', 'tok');
|
||||
localStorage.setItem('tracker_selected_product', 'acme');
|
||||
const fetchSpy = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ products: [{ productId: 'acme', displayName: 'Acme App' }] }),
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchSpy as unknown as typeof fetch);
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
let root!: Root;
|
||||
function Harness() {
|
||||
const { products, productName } = useProduct();
|
||||
return createElement(
|
||||
'div',
|
||||
null,
|
||||
createElement('span', { 'data-testid': 'names' }, products.map(p => p.name).join(',')),
|
||||
createElement('span', { 'data-testid': 'name' }, productName)
|
||||
);
|
||||
}
|
||||
await act(async () => {
|
||||
root = createRoot(container);
|
||||
root.render(createElement(ProductProvider, null, createElement(Harness)));
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'/api/products/mine',
|
||||
expect.objectContaining({ headers: { Authorization: 'Bearer tok' } })
|
||||
);
|
||||
expect(container.querySelector('[data-testid="names"]')?.textContent).toBe('Acme App');
|
||||
expect(container.querySelector('[data-testid="name"]')?.textContent).toBe('Acme App');
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
77
dashboards/tracker-web/src/__tests__/products-proxy.test.ts
Normal file
77
dashboards/tracker-web/src/__tests__/products-proxy.test.ts
Normal file
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Tests for /api/products/[...path] — products registry proxy (owner-scoped
|
||||
* "my projects" via GET /api/products/mine).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
import { GET } from '@/app/api/products/[...path]/route';
|
||||
|
||||
function mockNextRequest(
|
||||
method: string,
|
||||
body?: string,
|
||||
headers?: Record<string, string>
|
||||
): NextRequest {
|
||||
const headerMap = new Map(Object.entries(headers || {}));
|
||||
return {
|
||||
method,
|
||||
headers: {
|
||||
get: (key: string) => headerMap.get(key.toLowerCase()) || headerMap.get(key) || null,
|
||||
},
|
||||
nextUrl: { searchParams: new URLSearchParams() },
|
||||
text: async () => body || '',
|
||||
} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
describe('products proxy', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.PLATFORM_API_URL = 'http://localhost:4003';
|
||||
});
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('proxies GET /products/mine, forwarding the bearer token', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ products: [{ productId: 'acme' }] }),
|
||||
});
|
||||
const req = mockNextRequest('GET', undefined, { authorization: 'Bearer tok' });
|
||||
const res = await GET(req, { params: Promise.resolve({ path: ['mine'] }) });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/products/mine'),
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer tok' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('promotes x-tracker-token to a bearer when no Authorization is present', async () => {
|
||||
mockFetch.mockResolvedValue({ status: 200, text: async () => '{}' });
|
||||
const req = mockNextRequest('GET', undefined, { 'x-tracker-token': 'ttok' });
|
||||
await GET(req, { params: Promise.resolve({ path: ['mine'] }) });
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer ttok' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 502 when the products service is unavailable', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
const req = mockNextRequest('GET');
|
||||
const res = await GET(req, { params: Promise.resolve({ path: ['mine'] }) });
|
||||
expect(res.status).toBe(502);
|
||||
await expect(res.json()).resolves.toEqual({ error: 'Products service unavailable' });
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Catch-all proxy to platform-service products endpoints.
|
||||
* Forwards /api/products/* (notably GET /api/products/mine — the caller's
|
||||
* owner-scoped project list) to the platform-service registry.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const PLATFORM_API = process.env.PLATFORM_API_URL || 'http://localhost:4003';
|
||||
|
||||
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await params;
|
||||
// platform-service mounts product routes under the /api prefix.
|
||||
const targetPath = `/api/products/${path.join('/')}`;
|
||||
const url = new URL(targetPath, PLATFORM_API);
|
||||
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
url.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
const auth = req.headers.get('authorization');
|
||||
if (auth) headers['Authorization'] = auth;
|
||||
|
||||
const tokenHeader = req.headers.get('x-tracker-token');
|
||||
if (tokenHeader && !auth) headers['Authorization'] = `Bearer ${tokenHeader}`;
|
||||
|
||||
const productId = req.headers.get('x-product-id');
|
||||
if (productId) headers['x-product-id'] = productId;
|
||||
|
||||
const fetchOptions: RequestInit = { method: req.method, headers };
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
const body = await req.text();
|
||||
if (body) {
|
||||
fetchOptions.body = body;
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), fetchOptions);
|
||||
const data = await res.text();
|
||||
return new NextResponse(data, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Products service unavailable' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = proxy;
|
||||
export const POST = proxy;
|
||||
export const PUT = proxy;
|
||||
export const PATCH = proxy;
|
||||
export const DELETE = proxy;
|
||||
@ -20,8 +20,27 @@ function getInitialProduct(): string {
|
||||
return localStorage.getItem(STORAGE_KEY) || PRODUCT_ID;
|
||||
}
|
||||
|
||||
/** Map the platform-service product docs to the switcher's lightweight shape. */
|
||||
function toProducts(docs: unknown): Product[] {
|
||||
if (!Array.isArray(docs)) return [];
|
||||
const out: Product[] = [];
|
||||
for (const d of docs) {
|
||||
if (!d || typeof d !== 'object') continue;
|
||||
const { productId, id, displayName, name } = d as Record<string, unknown>;
|
||||
const pid = typeof productId === 'string' ? productId : typeof id === 'string' ? id : null;
|
||||
if (!pid) continue;
|
||||
const label =
|
||||
typeof displayName === 'string' ? displayName : typeof name === 'string' ? name : pid;
|
||||
out.push({ id: pid, name: label });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function ProductProvider({ children }: { children: ReactNode }) {
|
||||
const [productId, setProductIdState] = useState<string>(getInitialProduct);
|
||||
// Start from the configured list (env/default); replace with the caller's
|
||||
// owner-scoped projects once fetched. Falling back keeps dev/unauth working.
|
||||
const [products, setProducts] = useState<readonly Product[]>(KNOWN_PRODUCTS);
|
||||
|
||||
useEffect(() => {
|
||||
function syncSelectedProduct() {
|
||||
@ -36,6 +55,32 @@ export function ProductProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch the authenticated user's projects ("my projects") and use them as the
|
||||
// switcher list. Best-effort: any failure / empty result keeps the configured
|
||||
// fallback, so an unauthenticated or offline dashboard still renders.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const token = localStorage.getItem('tracker_token');
|
||||
if (!token) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/products/mine', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const json: unknown = await res.json();
|
||||
const list = toProducts((json as { products?: unknown }).products);
|
||||
if (!cancelled && list.length > 0) setProducts(list);
|
||||
} catch {
|
||||
/* keep the configured fallback */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setProductId = useCallback((id: string) => {
|
||||
setProductIdState(id);
|
||||
if (typeof window !== 'undefined') {
|
||||
@ -44,13 +89,11 @@ export function ProductProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const product = KNOWN_PRODUCTS.find(p => p.id === productId);
|
||||
const product = products.find(p => p.id === productId);
|
||||
const productName = product?.name ?? productId;
|
||||
|
||||
return (
|
||||
<ProductContext.Provider
|
||||
value={{ productId, productName, setProductId, products: KNOWN_PRODUCTS }}
|
||||
>
|
||||
<ProductContext.Provider value={{ productId, productName, setProductId, products }}>
|
||||
{children}
|
||||
</ProductContext.Provider>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user