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:
saravanakumardb1 2026-06-01 16:48:27 -07:00
parent 42c63dcc6e
commit acf7c36cda
4 changed files with 232 additions and 5 deletions

View File

@ -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());
});
});

View 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' });
});
});

View File

@ -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;

View File

@ -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>
);