Initial commit

This commit is contained in:
Grae Jones
2026-02-03 15:45:39 -08:00
commit 3647b304a3
74 changed files with 27121 additions and 0 deletions

6857
Client-TestApi/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View 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"
}
}

File diff suppressed because one or more lines are too long

View 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.
*/

View 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>

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

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

View 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`;

View File

@@ -0,0 +1,9 @@
import React from 'react';
export default function ResponsePanel() {
return (
<pre id="response-panel">
Response will appear here
</pre>
);
}

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

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

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

View 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 />);

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

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

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

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

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