Initial commit
This commit is contained in:
6857
Client-TestApi/package-lock.json
generated
Normal file
6857
Client-TestApi/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
Client-TestApi/package.json
Normal file
26
Client-TestApi/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "react-api-test-harness",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "webpack serve --mode development",
|
||||
"build": "webpack --mode production"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^3.27.0",
|
||||
"@azure/msal-react": "^2.1.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.5",
|
||||
"@babel/preset-env": "^7.24.5",
|
||||
"@babel/preset-react": "^7.24.5",
|
||||
"babel-loader": "^9.1.3",
|
||||
"css-loader": "^7.1.2",
|
||||
"style-loader": "^4.0.0",
|
||||
"webpack": "^5.91.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^5.2.3"
|
||||
}
|
||||
}
|
||||
2
Client-TestApi/public/bundle.js
Normal file
2
Client-TestApi/public/bundle.js
Normal file
File diff suppressed because one or more lines are too long
37
Client-TestApi/public/bundle.js.LICENSE.txt
Normal file
37
Client-TestApi/public/bundle.js.LICENSE.txt
Normal file
@@ -0,0 +1,37 @@
|
||||
/*! @azure/msal-browser v3.30.0 2025-08-05 */
|
||||
|
||||
/*! @azure/msal-common v14.16.1 2025-08-05 */
|
||||
|
||||
/*! @azure/msal-react v2.2.0 2024-11-05 */
|
||||
|
||||
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-dom.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* scheduler.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
11
Client-TestApi/public/index.html
Normal file
11
Client-TestApi/public/index.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>API Test Harness</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
38
Client-TestApi/src/app/App.js
Normal file
38
Client-TestApi/src/app/App.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { AuthProvider, useAuth } from '../auth/AuthProvider';
|
||||
import SignInOverlay from '../components/SignInOverlay';
|
||||
import Shell from '../components/Shell';
|
||||
|
||||
function AppContent() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
// Show loading state while checking auth
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="app-loading">
|
||||
<div className="loading-spinner" />
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
{/* Dashboard is always rendered but blurred when not authenticated */}
|
||||
<div className={`dashboard ${!isAuthenticated ? 'dashboard-blurred' : ''}`}>
|
||||
<Shell />
|
||||
</div>
|
||||
|
||||
{/* Sign-in overlay shown when not authenticated */}
|
||||
{!isAuthenticated && <SignInOverlay />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
197
Client-TestApi/src/auth/AuthProvider.jsx
Normal file
197
Client-TestApi/src/auth/AuthProvider.jsx
Normal file
@@ -0,0 +1,197 @@
|
||||
// src/auth/AuthProvider.jsx
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { PublicClientApplication, InteractionStatus } from '@azure/msal-browser';
|
||||
import { MsalProvider, useMsal, useIsAuthenticated } from '@azure/msal-react';
|
||||
import { msalConfig, loginRequest, SESSION_ENDPOINT } from './authConfig';
|
||||
|
||||
// Create MSAL instance
|
||||
const msalInstance = new PublicClientApplication(msalConfig);
|
||||
|
||||
// Initialize MSAL
|
||||
msalInstance.initialize().then(() => {
|
||||
// Handle redirect promise on page load
|
||||
msalInstance.handleRedirectPromise().catch(console.error);
|
||||
});
|
||||
|
||||
// Auth context for session management
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Inner provider that has access to MSAL hooks
|
||||
function AuthProviderInner({ children }) {
|
||||
const { instance, accounts, inProgress } = useMsal();
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
|
||||
const [sessionToken, setSessionToken] = useState(null);
|
||||
const [sessionUser, setSessionUser] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Get the current account
|
||||
const account = accounts[0] || null;
|
||||
|
||||
// Exchange Entra JWT for session token
|
||||
const exchangeForSession = useCallback(async (accessToken) => {
|
||||
try {
|
||||
setError(null);
|
||||
const response = await fetch(SESSION_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Session exchange failed: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Store session token
|
||||
setSessionToken(data.sessionToken || data.token);
|
||||
setSessionUser(data.user || {
|
||||
email: account?.username,
|
||||
name: account?.name,
|
||||
clientId: data.clientId,
|
||||
});
|
||||
|
||||
// Persist to sessionStorage
|
||||
sessionStorage.setItem('adp_session_token', data.sessionToken || data.token);
|
||||
sessionStorage.setItem('adp_session_user', JSON.stringify(data.user || {}));
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Session exchange error:', err);
|
||||
setError(err.message);
|
||||
return false;
|
||||
}
|
||||
}, [account]);
|
||||
|
||||
// Acquire token and exchange for session
|
||||
const initializeSession = useCallback(async () => {
|
||||
if (!account) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Try to get token silently first
|
||||
const tokenResponse = await instance.acquireTokenSilent({
|
||||
...loginRequest,
|
||||
account,
|
||||
});
|
||||
|
||||
// Exchange ID token for session token (idToken is for our app, accessToken is for Graph API)
|
||||
await exchangeForSession(tokenResponse.idToken);
|
||||
} catch (err) {
|
||||
console.error('Token acquisition error:', err);
|
||||
// If silent fails, we'll need interactive login
|
||||
setError('Please sign in again');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [account, instance, exchangeForSession]);
|
||||
|
||||
// Check for existing session on mount
|
||||
useEffect(() => {
|
||||
const existingToken = sessionStorage.getItem('adp_session_token');
|
||||
const existingUser = sessionStorage.getItem('adp_session_user');
|
||||
|
||||
if (existingToken && existingUser) {
|
||||
setSessionToken(existingToken);
|
||||
try {
|
||||
setSessionUser(JSON.parse(existingUser));
|
||||
} catch {
|
||||
setSessionUser({});
|
||||
}
|
||||
setIsLoading(false);
|
||||
} else if (isAuthenticated && inProgress === InteractionStatus.None) {
|
||||
initializeSession();
|
||||
} else if (inProgress === InteractionStatus.None) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isAuthenticated, inProgress, initializeSession]);
|
||||
|
||||
// Sign in handler
|
||||
const signIn = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
// Use redirect for External ID (more reliable)
|
||||
await instance.loginRedirect(loginRequest);
|
||||
} catch (err) {
|
||||
console.error('Sign in error:', err);
|
||||
setError(err.message);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [instance]);
|
||||
|
||||
// Sign out handler
|
||||
const signOut = useCallback(async () => {
|
||||
try {
|
||||
// Clear session storage
|
||||
sessionStorage.removeItem('adp_session_token');
|
||||
sessionStorage.removeItem('adp_session_user');
|
||||
setSessionToken(null);
|
||||
setSessionUser(null);
|
||||
|
||||
// Sign out from MSAL
|
||||
await instance.logoutRedirect();
|
||||
} catch (err) {
|
||||
console.error('Sign out error:', err);
|
||||
}
|
||||
}, [instance]);
|
||||
|
||||
// Refresh session (re-acquire token and exchange)
|
||||
const refreshSession = useCallback(async () => {
|
||||
await initializeSession();
|
||||
}, [initializeSession]);
|
||||
|
||||
const value = {
|
||||
// Auth state
|
||||
isAuthenticated: !!sessionToken,
|
||||
isLoading: isLoading || inProgress !== InteractionStatus.None,
|
||||
error,
|
||||
|
||||
// User info
|
||||
account,
|
||||
sessionUser,
|
||||
sessionToken,
|
||||
|
||||
// Actions
|
||||
signIn,
|
||||
signOut,
|
||||
refreshSession,
|
||||
clearError: () => setError(null),
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Main provider wrapping MSAL
|
||||
export function AuthProvider({ children }) {
|
||||
return (
|
||||
<MsalProvider instance={msalInstance}>
|
||||
<AuthProviderInner>
|
||||
{children}
|
||||
</AuthProviderInner>
|
||||
</MsalProvider>
|
||||
);
|
||||
}
|
||||
45
Client-TestApi/src/auth/authConfig.js
Normal file
45
Client-TestApi/src/auth/authConfig.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// src/auth/authConfig.js
|
||||
// Entra ID Configuration for USIMClients tenant
|
||||
|
||||
const TENANT_ID = '891f98f1-ed34-42a1-9b6c-28b0554d92c2';
|
||||
const CLIENT_ID = '154c9111-14a0-4c0f-8132-7bc68254a74e';
|
||||
|
||||
// Gateway API base URL
|
||||
export const API_BASE = 'https://adsapi.usimdev.com';
|
||||
|
||||
export const msalConfig = {
|
||||
auth: {
|
||||
clientId: CLIENT_ID,
|
||||
// Regular Entra ID uses login.microsoftonline.com
|
||||
authority: `https://login.microsoftonline.com/${TENANT_ID}`,
|
||||
redirectUri: window.location.origin,
|
||||
postLogoutRedirectUri: window.location.origin,
|
||||
navigateToLoginRequestUrl: true,
|
||||
},
|
||||
cache: {
|
||||
cacheLocation: 'sessionStorage',
|
||||
storeAuthStateInCookie: false,
|
||||
},
|
||||
system: {
|
||||
loggerOptions: {
|
||||
loggerCallback: (level, message, containsPii) => {
|
||||
if (containsPii) return;
|
||||
switch (level) {
|
||||
case 0: console.error(message); break;
|
||||
case 1: console.warn(message); break;
|
||||
case 2: console.info(message); break;
|
||||
case 3: console.debug(message); break;
|
||||
}
|
||||
},
|
||||
logLevel: 3, // Set to 0 for errors only in production
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Scopes to request during login
|
||||
export const loginRequest = {
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
};
|
||||
|
||||
// Session endpoint on Gateway
|
||||
export const SESSION_ENDPOINT = `${API_BASE}/api/auth/session`;
|
||||
9
Client-TestApi/src/components/ResponsePanel.jsx
Normal file
9
Client-TestApi/src/components/ResponsePanel.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function ResponsePanel() {
|
||||
return (
|
||||
<pre id="response-panel">
|
||||
Response will appear here
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
138
Client-TestApi/src/components/ServiceForm.js
Normal file
138
Client-TestApi/src/components/ServiceForm.js
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { providers } from '../services/providerCatalog';
|
||||
import { getServices, getService } from '../services/serviceCatalog';
|
||||
import { callApi } from '../services/apiClient';
|
||||
|
||||
// localStorage key for persisting tenant ID
|
||||
const TENANT_STORAGE_KEY = 'adp_tenantId';
|
||||
|
||||
export default function ServiceForm() {
|
||||
const { sessionToken, sessionUser } = useAuth();
|
||||
|
||||
const [providerId, setProviderId] = useState(providers[0].id);
|
||||
|
||||
const services = useMemo(() => getServices(providerId), [providerId]);
|
||||
const [serviceId, setServiceId] = useState(services?.[0]?.id || '');
|
||||
|
||||
const [input, setInput] = useState('{}');
|
||||
const [resp, setResp] = useState(null);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
|
||||
// Tenant ID (Google Ads Customer ID) - still persisted for convenience
|
||||
const [tenantId, setTenantId] = useState(() =>
|
||||
localStorage.getItem(TENANT_STORAGE_KEY) || ''
|
||||
);
|
||||
|
||||
// Get current service definition for display
|
||||
const currentService = useMemo(() => getService(providerId, serviceId), [providerId, serviceId]);
|
||||
|
||||
// Persist tenant ID changes
|
||||
useEffect(() => {
|
||||
localStorage.setItem(TENANT_STORAGE_KEY, tenantId);
|
||||
}, [tenantId]);
|
||||
|
||||
// Keep serviceId valid when provider changes
|
||||
useEffect(() => {
|
||||
const newServices = getServices(providerId);
|
||||
const firstService = newServices?.[0];
|
||||
setServiceId(firstService?.id || '');
|
||||
if (firstService?.sample) {
|
||||
setInput(JSON.stringify(firstService.sample, null, 2));
|
||||
}
|
||||
}, [providerId]);
|
||||
|
||||
// Update sample input when service changes
|
||||
useEffect(() => {
|
||||
const service = getService(providerId, serviceId);
|
||||
if (service?.sample) {
|
||||
setInput(JSON.stringify(service.sample, null, 2));
|
||||
}
|
||||
}, [serviceId, providerId]);
|
||||
|
||||
const submit = async () => {
|
||||
setIsBusy(true);
|
||||
setResp(null);
|
||||
try {
|
||||
const authConfig = {
|
||||
sessionToken,
|
||||
tenantId: tenantId.trim() || undefined,
|
||||
};
|
||||
const result = await callApi(providerId, serviceId, input, authConfig);
|
||||
setResp(result);
|
||||
} catch (e) {
|
||||
setResp({ ok: false, error: e?.message || String(e) });
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
{/* Session Info */}
|
||||
<div className="session-info">
|
||||
<span className="session-badge">
|
||||
<span className="session-dot" />
|
||||
Session Active
|
||||
</span>
|
||||
{sessionUser?.clientId && (
|
||||
<span className="client-badge">Client: {sessionUser.clientId}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tenant Configuration */}
|
||||
<fieldset className="auth-section">
|
||||
<legend>Target Account</legend>
|
||||
|
||||
<div className="row row-inline">
|
||||
<label>Tenant ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tenantId}
|
||||
onChange={(e) => setTenantId(e.target.value)}
|
||||
placeholder="Google Ads Customer ID (optional)"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Request Configuration Section */}
|
||||
<div className="row">
|
||||
<label>Provider</label>
|
||||
<select value={providerId} onChange={(e) => setProviderId(e.target.value)}>
|
||||
{providers.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<label>Operation</label>
|
||||
<select value={serviceId} onChange={(e) => setServiceId(e.target.value)}>
|
||||
{services.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Service/Action indicator */}
|
||||
{currentService && !currentService.endpoint && (
|
||||
<div className="row routing-info">
|
||||
<span className="route-badge">
|
||||
<strong>Route:</strong> {providerId} → {currentService.service} → {currentService.action}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="row">
|
||||
<label>Payload (JSON)</label>
|
||||
<textarea rows={12} value={input} onChange={(e) => setInput(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<button onClick={submit} disabled={!providerId || !serviceId || isBusy}>
|
||||
{isBusy ? 'Calling…' : 'Submit'}
|
||||
</button>
|
||||
|
||||
{resp && <pre className="response">{JSON.stringify(resp, null, 2)}</pre>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
Client-TestApi/src/components/Shell.jsx
Normal file
28
Client-TestApi/src/components/Shell.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import ServiceForm from './ServiceForm';
|
||||
import ResponsePanel from './ResponsePanel';
|
||||
|
||||
export default function Shell() {
|
||||
const { sessionUser, signOut } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="shell-header">
|
||||
<h2>API Test Harness</h2>
|
||||
<div className="user-info">
|
||||
<span className="user-name">
|
||||
{sessionUser?.name || sessionUser?.email || 'User'}
|
||||
</span>
|
||||
<button className="signout-button" onClick={signOut}>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="layout">
|
||||
<ServiceForm />
|
||||
<ResponsePanel />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
Client-TestApi/src/components/SignInOverlay.jsx
Normal file
65
Client-TestApi/src/components/SignInOverlay.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
// src/components/SignInOverlay.jsx
|
||||
import React from 'react';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
|
||||
export default function SignInOverlay() {
|
||||
const { signIn, isLoading, error, clearError } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="signin-overlay">
|
||||
<div className="signin-card">
|
||||
<div className="signin-header">
|
||||
<div className="signin-logo">
|
||||
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>AdPlatform</h1>
|
||||
<p className="signin-subtitle">API Test Harness</p>
|
||||
</div>
|
||||
|
||||
<div className="signin-body">
|
||||
{error && (
|
||||
<div className="signin-error">
|
||||
<span>{error}</span>
|
||||
<button className="error-dismiss" onClick={clearError}>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="signin-message">
|
||||
Sign in with your organization account to access the test dashboard.
|
||||
</p>
|
||||
|
||||
<button
|
||||
className="signin-button"
|
||||
onClick={signIn}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<span className="signin-spinner" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg viewBox="0 0 21 21" width="20" height="20" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
|
||||
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
|
||||
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
|
||||
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
|
||||
</svg>
|
||||
Sign in with Microsoft
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="signin-footer">
|
||||
<span>Powered by Azure Entra External ID</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
Client-TestApi/src/index.js
Normal file
7
Client-TestApi/src/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './app/App';
|
||||
import './styles/app.css';
|
||||
|
||||
const root = createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
57
Client-TestApi/src/services/apiClient.js
Normal file
57
Client-TestApi/src/services/apiClient.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// client/src/services/apiClient.js
|
||||
|
||||
/**
|
||||
* Centralized API client
|
||||
* Enforces auth contract:
|
||||
* - POST /api/auth/session -> Entra JWT (Authorization: Bearer <entraToken>)
|
||||
* - All other /api/* -> Session token (Authorization: Bearer <sessionToken>)
|
||||
*/
|
||||
|
||||
export async function callApi({
|
||||
url,
|
||||
method = 'GET',
|
||||
body,
|
||||
entraToken,
|
||||
sessionToken
|
||||
}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
// ---- AUTH CONTRACT ----
|
||||
if (url.startsWith('/api/auth/session')) {
|
||||
if (!entraToken) {
|
||||
throw new Error('callApi: entraToken required for /api/auth/session');
|
||||
}
|
||||
|
||||
headers['Authorization'] = `Bearer ${entraToken}`;
|
||||
}
|
||||
else if (url.startsWith('/api/')) {
|
||||
if (!sessionToken) {
|
||||
throw new Error('callApi: sessionToken required for authenticated API call');
|
||||
}
|
||||
|
||||
headers['Authorization'] = `Bearer ${sessionToken}`;
|
||||
// Optional but useful for server-side correlation
|
||||
headers['X-Requested-With'] = 'USIM-AdPlatform-Client';
|
||||
}
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
|
||||
// Handle auth failures centrally
|
||||
if (resp.status === 401) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`API 401: ${text}`);
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`API ${resp.status}: ${text}`);
|
||||
}
|
||||
|
||||
return resp.json();
|
||||
}
|
||||
7
Client-TestApi/src/services/providerCatalog.js
Normal file
7
Client-TestApi/src/services/providerCatalog.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export const providers = [
|
||||
{ id: 'gateway', label: 'Gateway (direct)' },
|
||||
{ id: 'google', label: 'Google Ads' }
|
||||
// later:
|
||||
// { id: 'microsoft', label: 'Microsoft Ads' },
|
||||
// { id: 'meta', label: 'Meta Ads' },
|
||||
];
|
||||
109
Client-TestApi/src/services/serviceCatalog.js
Normal file
109
Client-TestApi/src/services/serviceCatalog.js
Normal file
@@ -0,0 +1,109 @@
|
||||
// src/services/serviceCatalog.js
|
||||
|
||||
/**
|
||||
* Service catalog organized by Provider → Service → Action
|
||||
*
|
||||
* Structure:
|
||||
* - provider: Ad platform (google, meta, msads)
|
||||
* - service: Sub-module/microservice (system, campaigns, reporting, accounts)
|
||||
* - action: Specific operation (ping, create, list, get, update, delete)
|
||||
*/
|
||||
export const servicesByProvider = {
|
||||
gateway: [
|
||||
{
|
||||
id: 'GatewayPing',
|
||||
service: 'system',
|
||||
action: 'ping',
|
||||
label: 'Gateway Ping (SQL test)',
|
||||
sample: {},
|
||||
endpoint: '/api/test/ping',
|
||||
method: 'GET'
|
||||
}
|
||||
],
|
||||
google: [
|
||||
// System service
|
||||
{
|
||||
id: 'Ping',
|
||||
service: 'system',
|
||||
action: 'ping',
|
||||
label: 'System: Ping (GoogleApi round trip)',
|
||||
sample: {}
|
||||
},
|
||||
// Campaigns service
|
||||
{
|
||||
id: 'CreateCampaign',
|
||||
service: 'campaigns',
|
||||
action: 'create',
|
||||
label: 'Campaigns: Create',
|
||||
sample: { name: 'Test Campaign', budgetMicros: 10000000, type: 'Search' }
|
||||
},
|
||||
{
|
||||
id: 'ListCampaigns',
|
||||
service: 'campaigns',
|
||||
action: 'list',
|
||||
label: 'Campaigns: List',
|
||||
sample: {}
|
||||
},
|
||||
{
|
||||
id: 'GetCampaign',
|
||||
service: 'campaigns',
|
||||
action: 'get',
|
||||
label: 'Campaigns: Get by ID',
|
||||
sample: { campaignId: 'campaigns/123' }
|
||||
},
|
||||
// Reporting service
|
||||
{
|
||||
id: 'GetCampaignStats',
|
||||
service: 'reporting',
|
||||
action: 'campaignStats',
|
||||
label: 'Reporting: Campaign Stats',
|
||||
sample: { campaignId: 'campaigns/123', startDate: '2026-01-01', endDate: '2026-01-26' }
|
||||
},
|
||||
{
|
||||
id: 'GetAccountStats',
|
||||
service: 'reporting',
|
||||
action: 'accountStats',
|
||||
label: 'Reporting: Account Stats',
|
||||
sample: { startDate: '2026-01-01', endDate: '2026-01-26' }
|
||||
},
|
||||
// Accounts service
|
||||
{
|
||||
id: 'ListAccounts',
|
||||
service: 'accounts',
|
||||
action: 'list',
|
||||
label: 'Accounts: List Accessible',
|
||||
sample: {}
|
||||
},
|
||||
{
|
||||
id: 'GetAccount',
|
||||
service: 'accounts',
|
||||
action: 'get',
|
||||
label: 'Accounts: Get Details',
|
||||
sample: { customerId: '1234567890' }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export function getServices(providerId) {
|
||||
return servicesByProvider[providerId] || [];
|
||||
}
|
||||
|
||||
export function getService(providerId, serviceId) {
|
||||
return getServices(providerId).find(s => s.id === serviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique service modules for a provider
|
||||
*/
|
||||
export function getServiceModules(providerId) {
|
||||
const services = getServices(providerId);
|
||||
const modules = [...new Set(services.map(s => s.service))];
|
||||
return modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get actions for a specific service module
|
||||
*/
|
||||
export function getActionsForModule(providerId, serviceModule) {
|
||||
return getServices(providerId).filter(s => s.service === serviceModule);
|
||||
}
|
||||
456
Client-TestApi/src/styles/app.css
Normal file
456
Client-TestApi/src/styles/app.css
Normal file
@@ -0,0 +1,456 @@
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* App Container */
|
||||
.app-container {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* App Loading State */
|
||||
.app-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #e0e0e0;
|
||||
border-top-color: #0066cc;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Dashboard States */
|
||||
.dashboard {
|
||||
transition: filter 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.dashboard-blurred {
|
||||
filter: blur(8px);
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Sign-in Overlay */
|
||||
.signin-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.signin-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
overflow: hidden;
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.signin-header {
|
||||
background: linear-gradient(135deg, #0066cc 0%, #004c99 100%);
|
||||
color: white;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.signin-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.signin-logo svg {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.signin-header h1 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.signin-subtitle {
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.signin-body {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.signin-message {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin: 0 0 24px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.signin-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
color: #991b1b;
|
||||
font-size: 13px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.error-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #991b1b;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.signin-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 14px 24px;
|
||||
background: #2f2f2f;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.signin-button:hover:not(:disabled) {
|
||||
background: #1f1f1f;
|
||||
}
|
||||
|
||||
.signin-button:disabled {
|
||||
background: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.signin-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.signin-footer {
|
||||
padding: 16px 32px;
|
||||
background: #f9fafb;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* Shell Header */
|
||||
.shell-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.shell-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.signout-button {
|
||||
padding: 6px 12px;
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.signout-button:hover {
|
||||
background: #e5e7eb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Session Info in Form */
|
||||
.session-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.session-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: #dcfce7;
|
||||
border: 1px solid #86efac;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
color: #166534;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.session-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #22c55e;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.client-badge {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.shell {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
/* Auth Section */
|
||||
.auth-section {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 20px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.auth-section legend {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* Form Rows */
|
||||
.row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.row label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
color: #444;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.row-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.row-inline label {
|
||||
min-width: 90px;
|
||||
margin-bottom: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.row-inline input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
input[type="text"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #0066cc;
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.15);
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
button {
|
||||
background: #0066cc;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 24px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #0052a3;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Warning */
|
||||
.warning {
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
color: #856404;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Response */
|
||||
.response {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#response-panel {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
min-width: 300px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Routing Info */
|
||||
.routing-info {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.route-badge {
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
background: #e7f3ff;
|
||||
border: 1px solid #b3d9ff;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
.route-badge strong {
|
||||
color: #004c99;
|
||||
}
|
||||
34
Client-TestApi/webpack.config.js
Normal file
34
Client-TestApi/webpack.config.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
entry: './src/index.js',
|
||||
output: {
|
||||
filename: 'bundle.js',
|
||||
path: path.resolve(__dirname, 'public')
|
||||
},
|
||||
devServer: {
|
||||
static: './public',
|
||||
port: 3000
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.jsx?$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
presets: ['@babel/preset-env', '@babel/preset-react']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ['style-loader', 'css-loader']
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx']
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user