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
|
// @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 { act, createElement } from 'react';
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
|
||||||
@ -14,6 +14,10 @@ beforeEach(() => {
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
function renderProductHarness() {
|
function renderProductHarness() {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
@ -73,4 +77,51 @@ describe('ProductProvider', () => {
|
|||||||
|
|
||||||
cleanup();
|
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;
|
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 }) {
|
export function ProductProvider({ children }: { children: ReactNode }) {
|
||||||
const [productId, setProductIdState] = useState<string>(getInitialProduct);
|
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(() => {
|
useEffect(() => {
|
||||||
function syncSelectedProduct() {
|
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) => {
|
const setProductId = useCallback((id: string) => {
|
||||||
setProductIdState(id);
|
setProductIdState(id);
|
||||||
if (typeof window !== 'undefined') {
|
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;
|
const productName = product?.name ?? productId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProductContext.Provider
|
<ProductContext.Provider value={{ productId, productName, setProductId, products }}>
|
||||||
value={{ productId, productName, setProductId, products: KNOWN_PRODUCTS }}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</ProductContext.Provider>
|
</ProductContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user