First build
Some checks failed
Client Admin / build-deploy (push) Failing after 8s
Client Client / build-deploy (push) Failing after 3s
Client Registration / build-deploy (push) Failing after 20s
Client Tech / build-deploy (push) Failing after 1s
Client Home / build-deploy (push) Successful in 14s

This commit is contained in:
Grae Jones
2026-03-21 17:54:42 -07:00
parent 3647b304a3
commit fdb3e117a9
203 changed files with 35733 additions and 18189 deletions

2
Client-Tech/dist/bundle.js vendored Normal file

File diff suppressed because one or more lines are too long

37
Client-Tech/dist/bundle.js.LICENSE.txt vendored Normal file
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.
*/

26
Client-Tech/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "react-api-tech",
"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,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,159 @@
// src/auth/AuthProvider.jsx
//
// Staff-plane auth — NO Gateway session exchange.
// MSAL acquires a JWT from the org tenant; that JWT is sent directly to
// Management API as a Bearer token on every request. No session token exists.
//
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
import { PublicClientApplication, InteractionStatus } from '@azure/msal-browser';
import { MsalProvider, useMsal, useIsAuthenticated } from '@azure/msal-react';
import { msalConfig, loginRequest } from './authConfig';
const msalInstance = new PublicClientApplication(msalConfig);
msalInstance.initialize().then(() => {
msalInstance.handleRedirectPromise().catch(console.error);
});
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;
}
function AuthProviderInner({ children }) {
const { instance, accounts, inProgress } = useMsal();
const isAuthenticated = useIsAuthenticated();
const [accessToken, setAccessToken] = useState(null);
const [sessionUser, setSessionUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const initialized = useRef(false);
const account = accounts[0] || null;
// Acquire MSAL token silently and store it — no Gateway exchange.
const initializeSession = useCallback(async () => {
if (!account) { setIsLoading(false); return; }
try {
setIsLoading(true);
setError(null);
const tokenResponse = await instance.acquireTokenSilent({
...loginRequest,
account,
});
const jwt = tokenResponse.accessToken;
setAccessToken(jwt);
setSessionUser({
email: account.username,
name: account.name,
});
sessionStorage.setItem('adp_access_token', jwt);
} catch (err) {
console.error('[Auth] Token acquisition error:', err);
setError('Please sign in again');
} finally {
setIsLoading(false);
}
}, [account, instance]);
// On mount: restore from sessionStorage or acquire fresh.
useEffect(() => {
if (initialized.current) return;
if (inProgress !== InteractionStatus.None) return;
initialized.current = true;
const stored = sessionStorage.getItem('adp_access_token');
if (stored) {
// Optimistically restore — MSAL will silently refresh when it expires.
setAccessToken(stored);
setSessionUser({ email: account?.username, name: account?.name });
setIsLoading(false);
} else if (isAuthenticated) {
initializeSession();
} else {
// No account and no stored token — redirect straight to Microsoft login.
// Skips the app's own login screen entirely.
instance.loginRedirect(loginRequest).catch(err => {
console.error('[Auth] Auto-redirect failed:', err);
setIsLoading(false);
});
}
}, [isAuthenticated, inProgress, initializeSession, account, instance]);
const signIn = useCallback(async () => {
try {
setError(null);
await instance.loginRedirect(loginRequest);
} catch (err) {
console.error('[Auth] Sign in error:', err);
setError(err.message);
}
}, [instance]);
const signOut = useCallback(async () => {
try {
sessionStorage.removeItem('adp_access_token');
setAccessToken(null);
setSessionUser(null);
await instance.logoutRedirect();
} catch (err) {
console.error('[Auth] Sign out error:', err);
}
}, [instance]);
// Acquire a fresh token (MSAL handles caching/refresh automatically).
const refreshSession = useCallback(async () => {
await initializeSession();
}, [initializeSession]);
// Both helpers return the same MSAL JWT — Management API only needs Bearer.
const getAccessToken = useCallback(async () => {
if (!account) return null;
try {
const resp = await instance.acquireTokenSilent({ ...loginRequest, account });
return resp.accessToken;
} catch {
return accessToken;
}
}, [account, instance, accessToken]);
// Alias kept so components that call getIdToken() continue to work.
const getIdToken = getAccessToken;
const value = {
isAuthenticated: !!accessToken,
isLoading: isLoading || inProgress !== InteractionStatus.None,
error,
account,
sessionUser,
sessionToken: accessToken, // alias — components reading sessionToken get the JWT
signIn,
signOut,
refreshSession,
getIdToken,
getAccessToken,
clearError: () => setError(null),
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
export function AuthProvider({ children }) {
return (
<MsalProvider instance={msalInstance}>
<AuthProviderInner>
{children}
</AuthProviderInner>
</MsalProvider>
);
}

View File

@@ -0,0 +1,68 @@
/**
* authConfig.js — Tech Client (Staff Plane)
*
* ┌─────────────────────────────────────────────────────────────────────────┐
* │ PRODUCTION MIGRATION — only these values change at handoff: │
* │ │
* │ STAFF_AUTHORITY → 'https://login.microsoftonline.com/{ORG_TENANT}' │
* │ STAFF_TENANT_ID → new company org tenant ID │
* │ STAFF_CLIENT_ID → staff app registration in org tenant │
* │ │
* │ No other code changes required anywhere. │
* └─────────────────────────────────────────────────────────────────────────┘
*
* DEV NOTE: Staff currently authenticate against the CIAM tenant (same as
* clients) because no org tenant exists yet. The login screen looks identical
* to the client login — this is cosmetic only. API isolation is enforced by
* audience: staff tokens are rejected by Gateway, client tokens by Management.
*/
// ── Staff Identity Config ─────────────────────────────────────────────────────
const STAFF_TENANT_ID = '0be4c23a-6941-4bdb-b397-a4faf88de4b3';
const STAFF_CLIENT_ID = '846a3677-9135-4ba6-b7f5-933dcce126be';
// PROD: swap to → 'https://login.microsoftonline.com/' + STAFF_TENANT_ID
const STAFF_AUTHORITY = 'https://login.microsoftonline.com/' + STAFF_TENANT_ID;
// ── MSAL Config ───────────────────────────────────────────────────────────────
export const msalConfig = {
auth: {
clientId: STAFF_CLIENT_ID,
authority: STAFF_AUTHORITY,
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,
},
},
};
export const loginRequest = {
scopes: ["api://4e4d69c3-558a-4a27-a689-17bd397175e5/access_as_user"]
};
// ── API Endpoints ─────────────────────────────────────────────────────────────
export const API_BASE = 'https://adpapi.usimdev.com'; // Gateway API
export const MGMT_BASE = 'https://adpmgmt.usimdev.com'; // Management API
// Legacy — kept for backward compatibility with apiClient.js
export const SESSION_ENDPOINT = `${API_BASE}/api/auth/session`;

View File

@@ -0,0 +1,383 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../auth/AuthProvider';
import { MGMT_BASE } from '../auth/authConfig';
const PAGE_SIZE = 20;
// ─── Date presets ──────────────────────────────────────────────────────────────
function getPreset(key) {
const now = new Date();
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
switch (key) {
case 'today':
return { dateFrom: today.toISOString(), dateTo: null };
case 'yesterday': {
const d = new Date(today); d.setUTCDate(d.getUTCDate() - 1);
const e = new Date(today); e.setUTCMilliseconds(-1);
return { dateFrom: d.toISOString(), dateTo: e.toISOString() };
}
case '7d': {
const d = new Date(today); d.setUTCDate(d.getUTCDate() - 7);
return { dateFrom: d.toISOString(), dateTo: null };
}
case '30d': {
const d = new Date(today); d.setUTCDate(d.getUTCDate() - 30);
return { dateFrom: d.toISOString(), dateTo: null };
}
default: return { dateFrom: null, dateTo: null };
}
}
function isoToLocal(iso) {
if (!iso) return '';
const d = new Date(iso);
const p = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
}
const PRESETS = [
{ key: 'all', label: 'All time' },
{ key: 'today', label: 'Today' },
{ key: 'yesterday', label: 'Yesterday' },
{ key: '7d', label: 'Last 7 days' },
{ key: '30d', label: 'Last 30 days' },
{ key: 'custom', label: 'Custom...' },
];
// ─── Path breadcrumb ──────────────────────────────────────────────────────────
function parsePath(path) {
const parts = (path || '').replace(/^\/api\//, '').split('/').filter(Boolean);
const isId = s => /^[0-9a-f-]{8,}$/i.test(s) || /^\d+$/.test(s) || s.length > 24;
return parts.map(p => ({ value: p, isId: isId(p) }));
}
function PathCell({ path }) {
const segs = parsePath(path);
return (
<td title={path} style={{ padding: '8px 14px', maxWidth: 420 }}>
<span style={{ fontFamily: 'monospace', fontSize: 12, display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
{segs.map((seg, i) => (
<React.Fragment key={i}>
{i > 0 && <span style={{ color: '#cbd5e1', margin: '0 1px' }}>&#8250;</span>}
<span style={{
color: seg.isId ? '#7c3aed' : '#1e293b',
fontWeight: seg.isId ? 400 : 500,
fontStyle: seg.isId ? 'italic' : 'normal',
}}>
{seg.value}
</span>
</React.Fragment>
))}
</span>
</td>
);
}
// ─── Status badge ─────────────────────────────────────────────────────────────
function StatusBadge({ code }) {
const ok = code >= 200 && code < 300;
const warn = code >= 400 && code < 500;
const bg = ok ? '#dcfce7' : warn ? '#fef9c3' : '#fee2e2';
const color = ok ? '#15803d' : warn ? '#854d0e' : '#b91c1c';
return (
<span style={{ background: bg, color, padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 700 }}>
{code}
</span>
);
}
function fmt(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString(undefined, {
month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit', second: '2-digit',
});
}
function rel(iso) {
if (!iso) return '';
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (s < 60) return s + 's ago';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
if (s < 604800) return Math.floor(s / 86400) + 'd ago';
return new Date(iso).toLocaleDateString();
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function ActivityPanel() {
const { getAccessToken } = useAuth();
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [page, setPage] = useState(1);
const [staff, setStaff] = useState([]);
const [staffLoading, setStaffLoading] = useState(true);
const [filterOid, setFilterOid] = useState('');
const [preset, setPreset] = useState('all');
const [dateFrom, setDateFrom] = useState(null);
const [dateTo, setDateTo] = useState(null);
const [customFrom, setCustomFrom] = useState('');
const [customTo, setCustomTo] = useState('');
useEffect(() => {
async function loadStaff() {
try {
const token = await getAccessToken();
const res = await fetch(MGMT_BASE + '/api/monitoring/staff', {
headers: { Authorization: 'Bearer ' + token },
});
const data = await res.json();
if (data.ok) setStaff(data.staff || []);
} catch (e) {
console.warn('[Activity] Staff list failed:', e.message);
} finally {
setStaffLoading(false);
}
}
loadStaff();
}, [getAccessToken]);
const load = useCallback(async (pg, oid, from, to) => {
setLoading(true);
setError(null);
try {
const token = await getAccessToken();
const body = { page: pg, pageSize: PAGE_SIZE };
if (oid) body.oid = oid;
if (from) body.dateFrom = from;
if (to) body.dateTo = to;
const res = await fetch(MGMT_BASE + '/api/monitoring/activity', {
method: 'POST',
headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!data.ok && !data.items) throw new Error(data.error || 'HTTP ' + res.status);
setItems(data.items || []);
setTotal(data.total || 0);
setPage(pg);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}, [getAccessToken]);
useEffect(() => { load(1, '', null, null); }, []);
const applyPreset = (key) => {
setPreset(key);
if (key === 'custom') return;
const { dateFrom: f, dateTo: t } = getPreset(key);
setDateFrom(f);
setDateTo(t);
setCustomFrom(f ? isoToLocal(f) : '');
setCustomTo(t ? isoToLocal(t) : '');
load(1, filterOid, f, t);
};
const applyCustom = () => {
const f = customFrom ? new Date(customFrom).toISOString() : null;
const t = customTo ? new Date(customTo).toISOString() : null;
setDateFrom(f);
setDateTo(t);
load(1, filterOid, f, t);
};
const handleStaffChange = (oid) => {
setFilterOid(oid);
load(1, oid, dateFrom, dateTo);
};
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const selectedName = staff.find(s => s.oid === filterOid) && staff.find(s => s.oid === filterOid).displayName;
const presetLabel = (PRESETS.find(p => p.key === preset) || {}).label || '';
const pill = (active) => ({
padding: '6px 12px', borderRadius: 20, fontSize: 12, cursor: 'pointer',
border: active ? '1.5px solid #3b82f6' : '1px solid #e2e8f0',
background: active ? '#eff6ff' : '#f8fafc',
color: active ? '#1d4ed8' : '#475569',
fontWeight: active ? 600 : 400,
});
const pageBtn = (disabled) => ({
padding: '6px 14px', borderRadius: 5, border: '1px solid #e2e8f0',
background: disabled ? '#f8fafc' : '#fff',
cursor: disabled ? 'default' : 'pointer',
fontSize: 13, color: disabled ? '#cbd5e1' : '#374151',
});
return (
<div style={{ padding: 24, maxWidth: 1100 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#0f172a' }}>Admin Activity Log</h2>
<p style={{ margin: '4px 0 0', fontSize: 13, color: '#64748b' }}>
All mutating operations performed by staff members
</p>
</div>
<button
onClick={() => load(page, filterOid, dateFrom, dateTo)}
disabled={loading}
style={{ padding: '7px 16px', borderRadius: 6, border: '1px solid #e2e8f0', background: '#f8fafc', cursor: 'pointer', fontSize: 13, color: '#374151' }}
>
{loading ? 'Loading...' : '↻ Refresh'}
</button>
</div>
{/* Filter bar */}
<div style={{ display: 'flex', gap: 10, marginBottom: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{ position: 'relative', minWidth: 220 }}>
<select
value={filterOid}
onChange={e => handleStaffChange(e.target.value)}
disabled={staffLoading}
style={{
width: '100%', padding: '7px 28px 7px 12px', borderRadius: 6,
border: '1px solid #e2e8f0', fontSize: 13, background: '#fff',
color: filterOid ? '#1e293b' : '#94a3b8', cursor: 'pointer', appearance: 'none',
}}
>
<option value="">{staffLoading ? 'Loading...' : 'All staff (' + staff.length + ')'}</option>
{staff.map(s => (
<option key={s.oid} value={s.oid}>
{s.displayName || s.email}{s.displayName && s.email ? ' — ' + s.email : ''}
</option>
))}
</select>
<span style={{ position: 'absolute', right: 9, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none', color: '#94a3b8', fontSize: 11 }}></span>
</div>
<span style={{ color: '#e2e8f0' }}>|</span>
{PRESETS.map(p => (
<button key={p.key} onClick={() => applyPreset(p.key)} style={pill(preset === p.key)}>
{p.label}
</button>
))}
{filterOid && (
<button onClick={() => handleStaffChange('')} style={{ ...pill(false), borderRadius: 6 }}>
× Clear
</button>
)}
</div>
{/* Custom range inputs */}
{preset === 'custom' && (
<div style={{
display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12,
padding: '10px 14px', borderRadius: 8, background: '#f8fafc', border: '1px solid #e2e8f0',
}}>
<label style={{ fontSize: 12, color: '#64748b', whiteSpace: 'nowrap' }}>From</label>
<input type="datetime-local" value={customFrom} onChange={e => setCustomFrom(e.target.value)}
style={{ padding: '5px 10px', borderRadius: 6, border: '1px solid #e2e8f0', fontSize: 13 }} />
<label style={{ fontSize: 12, color: '#64748b', whiteSpace: 'nowrap' }}>To</label>
<input type="datetime-local" value={customTo} onChange={e => setCustomTo(e.target.value)}
style={{ padding: '5px 10px', borderRadius: 6, border: '1px solid #e2e8f0', fontSize: 13 }} />
<button onClick={applyCustom}
style={{ padding: '6px 16px', borderRadius: 6, border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>
Apply
</button>
</div>
)}
{error && (
<div style={{ padding: 12, borderRadius: 6, background: '#fee2e2', color: '#b91c1c', marginBottom: 12, fontSize: 13 }}>
{error}
</div>
)}
{/* Table */}
<div style={{ border: '1px solid #e2e8f0', borderRadius: 8, overflow: 'hidden', background: '#fff' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f8fafc', borderBottom: '1px solid #e2e8f0' }}>
{['Timestamp', 'Who', 'Path', 'Status'].map(h => (
<th key={h} style={{ padding: '10px 14px', textAlign: 'left', fontWeight: 600, color: '#475569', fontSize: 12 }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{loading && items.length === 0 ? (
<tr><td colSpan={4} style={{ padding: 32, textAlign: 'center', color: '#94a3b8' }}>Loading...</td></tr>
) : items.length === 0 ? (
<tr><td colSpan={4} style={{ padding: 32, textAlign: 'center', color: '#94a3b8' }}>No activity in this range.</td></tr>
) : items.map((row, i) => (
<React.Fragment key={row.activityId}>
<tr style={{
borderBottom: row.filter ? 'none' : '1px solid #f1f5f9',
background: i % 2 === 0 ? '#fff' : '#fafafa',
opacity: loading ? 0.6 : 1, transition: 'opacity 0.15s',
}}>
<td title={rel(row.activityAt)} style={{ padding: '8px 14px', whiteSpace: 'nowrap' }}>
<span style={{ color: '#475569', fontSize: 12, fontFamily: 'monospace' }}>
{fmt(row.activityAt)}
</span>
</td>
<td title={row.email || row.oid || ''} style={{ padding: '8px 14px', whiteSpace: 'nowrap' }}>
<span style={{ fontWeight: 500, color: '#1e293b' }}>
{row.displayName || row.email || row.oid || '—'}
</span>
</td>
<PathCell path={row.path} />
<td style={{ padding: '8px 14px' }}>
<StatusBadge code={row.statusCode} />
</td>
</tr>
{row.filter && (
<tr style={{
borderBottom: '1px solid #f1f5f9',
background: i % 2 === 0 ? '#fff' : '#fafafa',
opacity: loading ? 0.6 : 1,
}}>
<td colSpan={4} style={{ padding: '0 14px 7px', textAlign: 'center' }}>
<span style={{
fontFamily: 'monospace', fontSize: 11,
color: '#94a3b8', letterSpacing: '0.01em',
}}>
{row.filter}
</span>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
{/* Pagination — always visible */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 12 }}>
<span style={{ fontSize: 12, color: '#64748b' }}>
{total.toLocaleString()} record{total !== 1 ? 's' : ''}
{selectedName ? ' · ' + selectedName : ''}
{preset !== 'all' ? ' · ' + presetLabel.toLowerCase() : ''}
</span>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button disabled={page <= 1 || loading} onClick={() => load(page - 1, filterOid, dateFrom, dateTo)}
style={pageBtn(page <= 1 || loading)}>
Prev
</button>
<span style={{ fontSize: 12, color: '#94a3b8', minWidth: 70, textAlign: 'center' }}>
{page} / {totalPages}
</span>
<button disabled={page >= totalPages || loading} onClick={() => load(page + 1, filterOid, dateFrom, dateTo)}
style={pageBtn(page >= totalPages || loading)}>
Next
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,165 @@
import React, { useState } from 'react';
import { providers } from '../services/providerCatalog';
import ServiceForm from './ServiceForm';
import GoogleTokenTool from './GoogleTokenTool';
const PROVIDER_ICONS = {
gateway: '🔀',
google: '🔍',
meta: '📘',
tiktok: '🎵',
creative: '🎨',
intelligence: '🧠',
registration: '📝',
management: '⚙️',
};
const PROVIDER_DESC = {
gateway: 'Direct Gateway calls — wizard, intelligence, ping',
google: 'Google Ads API via internal GoogleApi container',
meta: 'Meta Ads API via internal MetaApi container',
tiktok: 'TikTok Ads API via internal TikTokApi container',
creative: 'Creative pipeline — URL analysis, copy, images',
intelligence: 'Spend distribution forecast engine',
registration: 'Registration API — prospects and onboarding',
management: 'Management API — help content, documents',
};
export default function ApiTestingPanel() {
const [selectedProvider, setSelectedProvider] = useState(null);
const [showTokenTool, setShowTokenTool] = useState(false);
if (showTokenTool) {
return (
<div className="content-panel">
<button
className="token-btn"
onClick={() => setShowTokenTool(false)}
style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 6 }}
>
Back to API Testing
</button>
<GoogleTokenTool />
</div>
);
}
if (selectedProvider) {
return (
<div>
<div style={{ padding: '0 0 12px' }}>
<button
className="token-btn"
onClick={() => setSelectedProvider(null)}
style={{ display: 'flex', alignItems: 'center', gap: 6 }}
>
Back to API Testing
</button>
</div>
<ServiceForm initialProvider={selectedProvider} />
</div>
);
}
return (
<div className="content-panel" style={{ maxWidth: 860 }}>
<div style={{ marginBottom: 20 }}>
<h3 style={{ margin: 0, fontSize: 16, color: '#1e293b' }}>API Testing</h3>
<p style={{ margin: '4px 0 0', fontSize: 13, color: '#64748b' }}>
Select a provider to test its endpoints
</p>
</div>
{/* Provider grid */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: 12,
marginBottom: 24
}}>
{providers.filter(p => p.id !== 'management' || true).map(p => (
<button
key={p.id}
onClick={() => setSelectedProvider(p.id)}
style={{
background: '#fff',
border: '1px solid #e2e8f0',
borderRadius: 8,
padding: '16px 18px',
textAlign: 'left',
cursor: 'pointer',
transition: 'all 0.15s',
display: 'flex',
alignItems: 'flex-start',
gap: 12,
}}
onMouseEnter={e => {
e.currentTarget.style.borderColor = '#0066cc';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(0,102,204,0.1)';
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = '#e2e8f0';
e.currentTarget.style.boxShadow = 'none';
}}
>
<span style={{ fontSize: 22, flexShrink: 0, marginTop: 1 }}>
{PROVIDER_ICONS[p.id] || '📡'}
</span>
<div>
<div style={{ fontWeight: 600, fontSize: 14, color: '#1e293b', marginBottom: 3 }}>
{p.label}
</div>
<div style={{ fontSize: 12, color: '#64748b', lineHeight: 1.4 }}>
{PROVIDER_DESC[p.id] || 'Test API endpoints'}
</div>
</div>
</button>
))}
</div>
{/* Google OAuth tool */}
<div style={{
borderTop: '1px solid #e2e8f0',
paddingTop: 20
}}>
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: 1, color: '#94a3b8', marginBottom: 12 }}>
TOOLS
</div>
<button
onClick={() => setShowTokenTool(true)}
style={{
background: '#fff',
border: '1px solid #e2e8f0',
borderRadius: 8,
padding: '14px 18px',
textAlign: 'left',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 12,
width: '100%',
maxWidth: 300,
}}
onMouseEnter={e => {
e.currentTarget.style.borderColor = '#0066cc';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(0,102,204,0.1)';
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = '#e2e8f0';
e.currentTarget.style.boxShadow = 'none';
}}
>
<span style={{ fontSize: 20 }}>🔑</span>
<div>
<div style={{ fontWeight: 600, fontSize: 14, color: '#1e293b', marginBottom: 2 }}>
Google OAuth Token
</div>
<div style={{ fontSize: 12, color: '#64748b' }}>
Generate and inspect OAuth tokens
</div>
</div>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,192 @@
import React, { useState } from 'react';
/**
* Renders a visual preview of Audience segments data including
* affinity, in-market, life events, and detailed demographics.
* Also handles geo target search results.
*/
export default function AudiencePreview({ data }) {
const [expandedSections, setExpandedSections] = useState({
affinity: true,
inMarket: true,
lifeEvents: true,
detailedDemographics: false
});
if (!data) return null;
// Handle nested response structure
const segments = data?.result?.data || data?.data || data;
// Check if this is a geo search result
const geoResults = segments?.results || [];
const geoQuery = segments?.query;
if (geoQuery !== undefined || geoResults.length > 0) {
return (
<div className="audience-preview">
<h4 className="preview-title">
🌍 Location Search Results
<span className="source-badge">Query: "{geoQuery}"</span>
</h4>
<div className="geo-results">
{geoResults.length === 0 ? (
<p className="no-results">No locations found</p>
) : (
<table className="geo-table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Type</th>
<th>Full Name</th>
</tr>
</thead>
<tbody>
{geoResults.map((geo, idx) => (
<tr key={geo.id || idx}>
<td className="geo-id">{geo.id}</td>
<td className="geo-name">{geo.name}</td>
<td>
<span className={`type-badge type-${(geo.targetType || '').toLowerCase()}`}>
{geo.targetType}
</span>
</td>
<td className="geo-canonical">{geo.canonicalName}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
// Audience segments view
const affinity = segments?.affinity || [];
const inMarket = segments?.inMarket || [];
const lifeEvents = segments?.lifeEvents || [];
const detailedDemographics = segments?.detailedDemographics || [];
const totalCount = segments?.totalCount || 0;
const retrievedAt = segments?.retrievedAt;
if (totalCount === 0 && affinity.length === 0) {
return null;
}
const toggleSection = (section) => {
setExpandedSections(prev => ({
...prev,
[section]: !prev[section]
}));
};
const renderSegmentList = (segmentList, sectionKey, icon) => {
const isExpanded = expandedSections[sectionKey];
const displayCount = isExpanded ? segmentList.length : Math.min(5, segmentList.length);
return (
<div className="segment-list">
{segmentList.slice(0, displayCount).map((seg, idx) => (
<div key={seg.id || idx} className="segment-item">
<span className="segment-icon">{icon}</span>
<span className="segment-name">{seg.name}</span>
<span className="segment-id">ID: {seg.id}</span>
</div>
))}
{segmentList.length > 5 && (
<button
className="toggle-btn"
onClick={() => toggleSection(sectionKey)}
>
{isExpanded ? '▲ Show less' : `▼ Show all ${segmentList.length}`}
</button>
)}
</div>
);
};
return (
<div className="audience-preview">
<h4 className="preview-title">
👥 Audience Segments
<span className="source-badge">Total: {totalCount}</span>
</h4>
{retrievedAt && (
<div className="retrieved-at">
Retrieved: {new Date(retrievedAt).toLocaleString()}
</div>
)}
{/* Affinity Audiences */}
{affinity.length > 0 && (
<div className="segment-section">
<h5 onClick={() => toggleSection('affinity')} className="section-header clickable">
<span>💫 Affinity ({affinity.length})</span>
<span className="expand-icon">{expandedSections.affinity ? '▼' : '▶'}</span>
</h5>
<p className="section-desc">Reach users based on lifestyle, interests, and habits</p>
{expandedSections.affinity && renderSegmentList(affinity, 'affinity', '💫')}
</div>
)}
{/* In-Market Audiences */}
{inMarket.length > 0 && (
<div className="segment-section">
<h5 onClick={() => toggleSection('inMarket')} className="section-header clickable">
<span>🛒 In-Market ({inMarket.length})</span>
<span className="expand-icon">{expandedSections.inMarket ? '▼' : '▶'}</span>
</h5>
<p className="section-desc">Reach users actively researching or planning to purchase</p>
{expandedSections.inMarket && renderSegmentList(inMarket, 'inMarket', '🛒')}
</div>
)}
{/* Life Events */}
{lifeEvents.length > 0 && (
<div className="segment-section">
<h5 onClick={() => toggleSection('lifeEvents')} className="section-header clickable">
<span>🎯 Life Events ({lifeEvents.length})</span>
<span className="expand-icon">{expandedSections.lifeEvents ? '▼' : '▶'}</span>
</h5>
<p className="section-desc">Reach users during important life milestones</p>
{expandedSections.lifeEvents && renderSegmentList(lifeEvents, 'lifeEvents', '🎯')}
</div>
)}
{/* Detailed Demographics */}
{detailedDemographics.length > 0 && (
<div className="segment-section">
<h5 onClick={() => toggleSection('detailedDemographics')} className="section-header clickable">
<span>📊 Detailed Demographics ({detailedDemographics.length})</span>
<span className="expand-icon">{expandedSections.detailedDemographics ? '▼' : '▶'}</span>
</h5>
<p className="section-desc">Parental status, education, homeownership, employment</p>
{expandedSections.detailedDemographics && renderSegmentList(detailedDemographics, 'detailedDemographics', '📊')}
</div>
)}
{/* Quick Reference */}
<div className="quick-reference">
<h5>📋 Static Demographics (no API needed)</h5>
<div className="static-options">
<div className="option-group">
<strong>Age:</strong> 18-24, 25-34, 35-44, 45-54, 55-64, 65+
</div>
<div className="option-group">
<strong>Gender:</strong> Male, Female, Unknown
</div>
<div className="option-group">
<strong>Income:</strong> Top 10%, 11-20%, 21-30%, 31-40%, 41-50%, Lower 50%
</div>
<div className="option-group">
<strong>Parental:</strong> Parent, Not a parent, Unknown
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
import React from 'react';
/**
* Renders a visual preview of Creative draft data including images,
* headlines, and descriptions.
*/
export default function CreativePreview({ data }) {
if (!data) return null;
// Handle nested response structure: result.data or just data
const draft = data?.result?.data || data?.data || data;
const images = draft?.images || [];
const headlines = draft?.headlines || [];
const descriptions = draft?.descriptions || [];
const analysis = draft?.analysis;
const source = draft?.source;
const imageSource = draft?.imageSource;
// Only show preview if we have images or assets
if (images.length === 0 && headlines.length === 0) {
return null;
}
return (
<div className="creative-preview">
<h4 className="preview-title">
Creative Preview
{source && <span className="source-badge">Copy: {source}</span>}
{imageSource && <span className="source-badge">Images: {imageSource}</span>}
</h4>
{/* Analysis Summary */}
{analysis && (
<div className="analysis-summary">
<div className="analysis-title">{analysis.title || 'Untitled'}</div>
{analysis.inferredCategory && (
<span className="category-badge">{analysis.inferredCategory}</span>
)}
{analysis.metaDescription && (
<p className="analysis-desc">{analysis.metaDescription}</p>
)}
</div>
)}
{/* Images Grid */}
{images.length > 0 && (
<div className="images-section">
<h5>Images ({images.length})</h5>
<div className="images-grid">
{images.map((img, idx) => (
<div key={img.imageId || idx} className="image-card">
<div className="image-wrapper">
<img
src={img.url}
alt={img.altText || `${img.orientation} image`}
loading="lazy"
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'flex';
}}
/>
<div className="image-error" style={{ display: 'none' }}>
Failed to load
</div>
</div>
<div className="image-meta">
<span className="orientation-badge">{img.orientation}</span>
<span className="dimensions">{img.width}×{img.height}</span>
</div>
{img.attribution && (
<div className="attribution">{img.attribution}</div>
)}
{img.blobStored && (
<div className="blob-indicator" title="Stored in blob storage">
Stored
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Headlines */}
{headlines.length > 0 && (
<div className="assets-section">
<h5>Headlines ({headlines.length})</h5>
<div className="assets-list">
{headlines.map((h, idx) => (
<div key={idx} className="asset-item headline">
<span className="asset-text">{h.text}</span>
<span className="char-count">{h.charCount}/30</span>
</div>
))}
</div>
</div>
)}
{/* Descriptions */}
{descriptions.length > 0 && (
<div className="assets-section">
<h5>Descriptions ({descriptions.length})</h5>
<div className="assets-list">
{descriptions.map((d, idx) => (
<div key={idx} className="asset-item description">
<span className="asset-text">{d.text}</span>
<span className="char-count">{d.charCount}/90</span>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,450 @@
import React, { useState, useEffect, useRef } from 'react';
import { useAuth } from '../auth/AuthProvider';
import { MGMT_BASE } from '../auth/authConfig';
const CATEGORIES = ['Operations', 'Technical'];
const CATEGORY_COLORS = {
Operations: { bg: '#f3e8ff', text: '#6b21a8' },
Technical: { bg: '#dcfce7', text: '#166534' },
};
function formatBytes(bytes) {
if (!bytes) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`;
}
function formatDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
hour: 'numeric', minute: '2-digit'
});
}
function CategoryBadge({ category }) {
const style = CATEGORY_COLORS[category] || { bg: '#f1f5f9', text: '#475569' };
return (
<span style={{
background: style.bg, color: style.text,
padding: '2px 8px', borderRadius: 12,
fontSize: 11, fontWeight: 600, letterSpacing: 0.3
}}>
{category || 'Uncategorized'}
</span>
);
}
function FileIcon({ mimeType }) {
if (!mimeType) return '📄';
if (mimeType.includes('pdf')) return '📕';
if (mimeType.includes('word') || mimeType.includes('document')) return '📘';
if (mimeType.includes('sheet') || mimeType.includes('excel')) return '📗';
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return '📙';
if (mimeType.includes('image')) return '🖼';
if (mimeType.includes('text')) return '📄';
if (mimeType.includes('zip') || mimeType.includes('compressed')) return '📦';
return '📄';
}
export default function DocumentsPanel() {
const { getIdToken, sessionUser } = useAuth();
const [docs, setDocs] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState(null);
const [deleteId, setDeleteId] = useState(null); // confirm dialog
const [deleting, setDeleting] = useState(false);
const [downloading, setDownloading] = useState(null); // docId being downloaded
const [filterCat, setFilterCat] = useState('');
const [search, setSearch] = useState('');
// Upload form state
const [showUpload, setShowUpload] = useState(false);
const [uploadFile, setUploadFile] = useState(null);
const [uploadCat, setUploadCat] = useState('Technical');
const [uploadDesc, setUploadDesc] = useState('');
const fileInputRef = useRef();
const authHeader = async () => {
const token = await getIdToken();
return token ? { Authorization: `Bearer ${token}` } : {};
};
// ─── Load documents ─────────────────────────────────────────────────────────
const loadDocs = async () => {
setLoading(true);
setError(null);
try {
const resp = await fetch(`${MGMT_BASE}/api/documents/list`, {
method: 'POST',
headers: { ...await authHeader(), 'Content-Type': 'application/json' },
body: JSON.stringify({ categories: CATEGORIES })
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
setDocs(data.documents || []);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => { loadDocs(); }, []);
// ─── Upload ──────────────────────────────────────────────────────────────────
const handleUpload = async () => {
if (!uploadFile) return;
setUploading(true);
setUploadError(null);
try {
const form = new FormData();
form.append('file', uploadFile);
form.append('category', uploadCat);
form.append('description', uploadDesc);
form.append('uploadedBy', sessionUser?.name || sessionUser?.email || 'Unknown');
const resp = await fetch(`${MGMT_BASE}/api/documents`, {
method: 'POST',
headers: await authHeader(), // no Content-Type — browser sets multipart boundary
body: form
});
if (!resp.ok) {
const txt = await resp.text();
throw new Error(`HTTP ${resp.status}: ${txt}`);
}
setShowUpload(false);
setUploadFile(null);
setUploadDesc('');
setUploadCat('Technical');
await loadDocs();
} catch (err) {
setUploadError(err.message);
} finally {
setUploading(false);
}
};
// ─── Download ────────────────────────────────────────────────────────────────
const handleDownload = async (doc) => {
setDownloading(doc.docId);
try {
const resp = await fetch(`${MGMT_BASE}/api/documents/${doc.docId}/download`, {
headers: await authHeader()
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = doc.docFileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
alert(`Download failed: ${err.message}`);
} finally {
setDownloading(null);
}
};
// ─── Delete ──────────────────────────────────────────────────────────────────
const handleDelete = async () => {
if (!deleteId) return;
setDeleting(true);
try {
const resp = await fetch(`${MGMT_BASE}/api/documents/${deleteId}`, {
method: 'DELETE',
headers: await authHeader()
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
setDeleteId(null);
await loadDocs();
} catch (err) {
alert(`Delete failed: ${err.message}`);
} finally {
setDeleting(false);
}
};
// ─── Filtered list ────────────────────────────────────────────────────────────
const filtered = docs.filter(d => {
const matchCat = !filterCat || d.docCategory === filterCat;
const matchSrch = !search ||
d.docFileName.toLowerCase().includes(search.toLowerCase()) ||
(d.docDescription || '').toLowerCase().includes(search.toLowerCase()) ||
(d.docUploadedBy || '').toLowerCase().includes(search.toLowerCase());
return matchCat && matchSrch;
});
// ─── Render ───────────────────────────────────────────────────────────────────
return (
<div className="content-panel" style={{ maxWidth: 1000 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<h3 style={{ margin: 0, fontSize: 16, color: '#1e293b' }}>Project Documents</h3>
<p style={{ margin: '4px 0 0', fontSize: 13, color: '#64748b' }}>
{docs.length} document{docs.length !== 1 ? 's' : ''} stored
</p>
</div>
<button
className="token-btn token-btn-primary"
onClick={() => { setShowUpload(true); setUploadError(null); }}
style={{ display: 'flex', alignItems: 'center', gap: 6 }}
>
Upload Document
</button>
</div>
{/* Upload form */}
{showUpload && (
<div style={{
background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 8,
padding: 20, marginBottom: 20
}}>
<div style={{ fontWeight: 600, marginBottom: 14, color: '#1e293b' }}>Upload New Document</div>
{/* File picker */}
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>File</label>
<div
style={{
border: `2px dashed ${uploadFile ? '#0066cc' : '#cbd5e1'}`,
borderRadius: 6, padding: '16px 20px', textAlign: 'center',
cursor: 'pointer', background: uploadFile ? '#eff6ff' : '#fff',
transition: 'all 0.2s'
}}
onClick={() => fileInputRef.current?.click()}
>
{uploadFile ? (
<span style={{ color: '#0066cc', fontWeight: 500 }}>
{FileIcon(uploadFile.type)} {uploadFile.name} ({formatBytes(uploadFile.size)})
</span>
) : (
<span style={{ color: '#94a3b8' }}>Click to select a file</span>
)}
<input
ref={fileInputRef}
type="file"
style={{ display: 'none' }}
onChange={e => setUploadFile(e.target.files[0] || null)}
/>
</div>
</div>
{/* Category + Description row */}
<div style={{ display: 'grid', gridTemplateColumns: '180px 1fr', gap: 12, marginBottom: 12 }}>
<div>
<label style={labelStyle}>Category</label>
<select
value={uploadCat}
onChange={e => setUploadCat(e.target.value)}
style={inputStyle}
>
{CATEGORIES.map(c => <option key={c}>{c}</option>)}
</select>
</div>
<div>
<label style={labelStyle}>Description (optional)</label>
<input
type="text"
value={uploadDesc}
onChange={e => setUploadDesc(e.target.value)}
placeholder="Brief description of this document"
style={inputStyle}
/>
</div>
</div>
{uploadError && (
<div style={{ color: '#dc2626', fontSize: 13, marginBottom: 10 }}>
{uploadError}
</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<button
className="token-btn token-btn-primary"
onClick={handleUpload}
disabled={!uploadFile || uploading}
>
{uploading ? '⟳ Uploading…' : '↑ Upload'}
</button>
<button
className="token-btn"
onClick={() => { setShowUpload(false); setUploadFile(null); setUploadError(null); }}
disabled={uploading}
>
Cancel
</button>
</div>
</div>
)}
{/* Filters */}
<div style={{ display: 'flex', gap: 10, marginBottom: 16, alignItems: 'center' }}>
<input
type="text"
placeholder="Search documents…"
value={search}
onChange={e => setSearch(e.target.value)}
style={{ ...inputStyle, flex: 1, maxWidth: 280 }}
/>
<select
value={filterCat}
onChange={e => setFilterCat(e.target.value)}
style={{ ...inputStyle, width: 150 }}
>
<option value="">All Categories</option>
{CATEGORIES.map(c => <option key={c}>{c}</option>)}
</select>
<button className="token-btn" onClick={loadDocs} title="Refresh"></button>
</div>
{/* Content */}
{loading ? (
<div style={{ textAlign: 'center', padding: 40, color: '#94a3b8' }}>Loading documents</div>
) : error ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<div style={{ color: '#dc2626', marginBottom: 8 }}> {error}</div>
<button className="token-btn" onClick={loadDocs}>Retry</button>
</div>
) : filtered.length === 0 ? (
<div style={{
textAlign: 'center', padding: 48,
border: '1px dashed #e2e8f0', borderRadius: 8, color: '#94a3b8'
}}>
{docs.length === 0
? <>No documents yet. Click <strong>Upload Document</strong> to add the first one.</>
: 'No documents match the current filter.'}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{filtered.map(doc => (
<div
key={doc.docId}
style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 8,
padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14
}}
>
{/* Icon */}
<span style={{ fontSize: 24, flexShrink: 0 }}>
{FileIcon(doc.docMimeType)}
</span>
{/* Main info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{
fontWeight: 600, fontSize: 14, color: '#1e293b',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'
}}>
{doc.docFileName}
</span>
<CategoryBadge category={doc.docCategory} />
</div>
{doc.docDescription && (
<div style={{ fontSize: 13, color: '#64748b', marginBottom: 4 }}>
{doc.docDescription}
</div>
)}
<div style={{ fontSize: 12, color: '#94a3b8', display: 'flex', gap: 16 }}>
<span>{formatBytes(doc.docFileSize)}</span>
<span>Uploaded by {doc.docUploadedBy || '—'}</span>
<span>{formatDate(doc.docUploadedAt)}</span>
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button
className="token-btn token-btn-primary"
onClick={() => handleDownload(doc)}
disabled={downloading === doc.docId}
style={{ fontSize: 12, padding: '5px 12px' }}
>
{downloading === doc.docId ? '⟳' : '↓ Download'}
</button>
<button
className="token-btn"
onClick={() => setDeleteId(doc.docId)}
style={{ fontSize: 12, padding: '5px 12px', color: '#dc2626', borderColor: '#fecaca' }}
>
🗑 Delete
</button>
</div>
</div>
))}
</div>
)}
{/* Delete confirmation dialog */}
{deleteId && (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000
}}>
<div style={{
background: '#fff', borderRadius: 10, padding: 28,
maxWidth: 380, width: '90%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)'
}}>
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 8 }}>Delete Document?</div>
<div style={{ color: '#64748b', fontSize: 14, marginBottom: 20 }}>
{(() => {
const doc = docs.find(d => d.docId === deleteId);
return doc
? <>Are you sure you want to delete <strong>{doc.docFileName}</strong>? This cannot be undone.</>
: 'Are you sure you want to delete this document? This cannot be undone.';
})()}
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
className="token-btn"
onClick={() => setDeleteId(null)}
disabled={deleting}
>
Cancel
</button>
<button
className="token-btn"
onClick={handleDelete}
disabled={deleting}
style={{ background: '#dc2626', color: '#fff', borderColor: '#dc2626' }}
>
{deleting ? 'Deleting…' : 'Delete'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
// Shared input/label styles
const labelStyle = {
display: 'block', fontSize: 12, fontWeight: 600,
color: '#475569', marginBottom: 5, letterSpacing: 0.3
};
const inputStyle = {
width: '100%', padding: '7px 10px',
border: '1px solid #cbd5e1', borderRadius: 6,
fontSize: 13, color: '#1e293b', background: '#fff',
outline: 'none', boxSizing: 'border-box'
};

View File

@@ -0,0 +1,220 @@
import React, { useState, useEffect } from 'react';
/**
* Google OAuth Refresh Token Tool
*
* Handles the full flow:
* 1. Opens Google OAuth consent in a new tab
* 2. User copies the authorization code
* 3. Exchanges code for refresh token (direct to Google, no backend needed)
* 4. Shows the az CLI command to update the container
*
* Also supports auto-capture: if the TestAPI URL is registered as a redirect URI,
* the code will appear in the URL params and be auto-populated.
*/
// Default credentials (from your Google Cloud Console - Desktop app type)
const DEFAULTS = {
clientId: '330518338348-a1qto1jug5tmpc6565059apsggsfg12i.apps.googleusercontent.com',
clientSecret: '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',
scope: 'https://www.googleapis.com/auth/adwords',
containerApp: 'usim-adp-googleapi',
resourceGroup: 'RG-GraeJones'
};
export default function GoogleTokenTool() {
const [clientId, setClientId] = useState(DEFAULTS.clientId);
const [clientSecret, setClientSecret] = useState(DEFAULTS.clientSecret);
const [authCode, setAuthCode] = useState('');
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const [isBusy, setIsBusy] = useState(false);
const [copied, setCopied] = useState(null);
const [useRedirect, setUseRedirect] = useState(false);
const redirectUri = useRedirect
? window.location.origin
: 'urn:ietf:wg:oauth:2.0:oob';
// Auto-capture: check URL for ?code= on mount (if redirect mode is used)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
if (code) {
setAuthCode(code);
setUseRedirect(true);
// Clean the URL
window.history.replaceState({}, '', window.location.pathname);
}
}, []);
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
`client_id=${encodeURIComponent(clientId)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&response_type=code` +
`&scope=${encodeURIComponent(DEFAULTS.scope)}` +
`&access_type=offline` +
`&prompt=consent`;
const openAuth = () => {
window.open(authUrl, '_blank', 'width=600,height=700');
};
const exchangeCode = async () => {
if (!authCode.trim()) return;
setIsBusy(true);
setError(null);
setResult(null);
try {
const resp = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code: authCode.trim(),
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code'
})
});
const data = await resp.json();
if (data.error) {
setError(`${data.error}: ${data.error_description || 'Unknown error'}`);
} else {
setResult({
refreshToken: data.refresh_token,
accessToken: data.access_token,
expiresIn: data.expires_in,
scope: data.scope,
tokenType: data.token_type
});
}
} catch (e) {
setError(`Network error: ${e.message}`);
} finally {
setIsBusy(false);
}
};
const azCommand = result?.refreshToken
? `az containerapp update --name ${DEFAULTS.containerApp} --resource-group ${DEFAULTS.resourceGroup} --set-env-vars GoogleAds__OAuth__RefreshToken=${result.refreshToken}`
: '';
const copyToClipboard = (text, label) => {
navigator.clipboard.writeText(text).then(() => {
setCopied(label);
setTimeout(() => setCopied(null), 2000);
});
};
return (
<div className="token-tool">
<div className="token-tool-header">
<h3>Google OAuth Token Tool</h3>
<span className="token-tool-badge">Utility</span>
</div>
{/* Step 1: Credentials (pre-filled, collapsible) */}
<details className="token-step">
<summary>OAuth Credentials (pre-filled)</summary>
<div className="token-field">
<label>Client ID</label>
<input type="text" value={clientId} onChange={e => setClientId(e.target.value)} />
</div>
<div className="token-field">
<label>Client Secret</label>
<input type="text" value={clientSecret} onChange={e => setClientSecret(e.target.value)} />
</div>
<div className="token-field token-field-inline">
<label>
<input
type="checkbox"
checked={useRedirect}
onChange={e => setUseRedirect(e.target.checked)}
/>
{' '}Use redirect mode (requires {window.location.origin} in Google Cloud authorized redirect URIs)
</label>
</div>
</details>
{/* Step 2: Authorize */}
<div className="token-step-open">
<div className="step-label">Step 1 Authorize with Google</div>
<p className="step-hint">
Opens Google sign-in. Sign in with <strong>addplatform.mcc@gmail.com</strong> and grant access.
{!useRedirect && ' Copy the authorization code shown after approval.'}
</p>
<button className="token-btn token-btn-primary" onClick={openAuth}>
Open Google Authorization
</button>
</div>
{/* Step 3: Exchange code */}
<div className="token-step-open">
<div className="step-label">Step 2 Exchange Code for Token</div>
<div className="token-field">
<label>Authorization Code</label>
<input
type="text"
value={authCode}
onChange={e => setAuthCode(e.target.value)}
placeholder="Paste the authorization code here"
className={authCode ? 'has-value' : ''}
/>
</div>
<button
className="token-btn token-btn-primary"
onClick={exchangeCode}
disabled={!authCode.trim() || isBusy}
>
{isBusy ? 'Exchanging...' : 'Exchange for Refresh Token'}
</button>
</div>
{/* Error */}
{error && (
<div className="token-error">
<strong>Error:</strong> {error}
</div>
)}
{/* Result */}
{result && (
<div className="token-result">
<div className="step-label">Refresh Token</div>
<div className="token-value-row">
<code className="token-value">{result.refreshToken}</code>
<button
className="token-btn token-btn-small"
onClick={() => copyToClipboard(result.refreshToken, 'token')}
>
{copied === 'token' ? '✓ Copied' : 'Copy'}
</button>
</div>
<div className="step-label" style={{ marginTop: 16 }}>
Step 3 Update Container (run in PowerShell)
</div>
<div className="token-value-row">
<code className="token-value token-cmd">{azCommand}</code>
<button
className="token-btn token-btn-small"
onClick={() => copyToClipboard(azCommand, 'cmd')}
>
{copied === 'cmd' ? '✓ Copied' : 'Copy'}
</button>
</div>
<details className="token-details" style={{ marginTop: 12 }}>
<summary>Full token response</summary>
<pre>{JSON.stringify(result, null, 2)}</pre>
</details>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,362 @@
import React, { useState, useCallback } from 'react';
import { useAuth } from '../auth/AuthProvider';
import { API_BASE } from '../auth/authConfig';
const HELP_BASE = 'https://adpmgmt.usimdev.com/api/help';
// ─── Service definitions ──────────────────────────────────────────────────────
const SERVICE_META = {
gateway: {
label: 'Gateway API', sub: 'adsapi.usimdev.com', tier: 'public', color: '#0066cc',
mode: 'direct', url: `${API_BASE}/api/test/ping`,
tech: '.NET 8 · Azure Container Apps · SQL Server',
},
management: {
label: 'Management API', sub: 'adpmgmt.usimdev.com', tier: 'public', color: '#0ea5e9',
mode: 'direct', url: 'https://adpmgmt.usimdev.com/health',
tech: '.NET 8 · Azure Container Apps · SQL Server',
},
tech: {
label: 'Tech (TestAPI)', sub: 'adptestapi.usimdev.com', tier: 'public', color: '#64748b',
mode: 'none',
tech: 'React · MSAL · Azure Container Apps',
},
registration: {
label: 'Registration', sub: 'adpregapi.usimdev.com', tier: 'registration', color: '#f97316',
mode: 'direct', url: 'https://adpregapi.usimdev.com/api/health',
tech: 'Azure Functions · .NET 8',
},
creative: {
label: 'Creative', sub: 'OpenAI / DALL·E', tier: 'internal', color: '#8b5cf6',
mode: 'provider', provider: 'creative',
tech: 'OpenAI · DALL-E 3 · Unsplash · Azure Blob',
},
google: {
label: 'Google Ads', sub: 'GoogleApi', tier: 'internal', color: '#4285f4',
mode: 'provider', provider: 'google',
tech: 'Google Ads .NET Client Library · Key Vault',
},
meta: {
label: 'Meta Ads', sub: 'MetaApi', tier: 'internal', color: '#1877f2',
mode: 'provider', provider: 'meta',
tech: 'Meta Ads API · Business Manager',
},
tiktok: {
label: 'TikTok Ads', sub: 'TikTokApi', tier: 'internal', color: '#2d2d2d',
mode: 'provider', provider: 'tiktok',
tech: 'TikTok Ads API · Business Center',
},
intelligence: {
label: 'Intelligence', sub: 'IntelligenceApi', tier: 'internal', color: '#10b981',
mode: 'provider', provider: 'intelligence',
tech: '.NET 8 · Azure Container Apps · Internal Only',
},
};
const TIER_PUBLIC = ['management', 'tech', 'gateway'];
const TIER_REG = ['registration'];
const TIER_INTERNAL = ['creative', 'google', 'meta', 'tiktok', 'intelligence'];
const CHECKABLE = [...TIER_PUBLIC, ...TIER_REG, ...TIER_INTERNAL].filter(
id => SERVICE_META[id].mode !== 'none'
);
// ─── Status helpers ───────────────────────────────────────────────────────────
const statusColor = (s) => ({ healthy: '#22c55e', error: '#f59e0b', unreachable: '#ef4444' }[s] || '#cbd5e1');
const statusLabel = (s) => ({ healthy: 'Healthy', error: 'Error', unreachable: 'Unreachable' }[s] || 'Not checked');
// ─── Service Node ─────────────────────────────────────────────────────────────
function ServiceNode({ id, result, onClick }) {
const svc = SERVICE_META[id];
const sc = statusColor(result?.status);
return (
<button
className="svc-node"
onClick={() => onClick(id)}
style={{ borderColor: result ? sc : undefined }}
>
{result && (
<span className="svc-node-dot" style={{ background: sc, boxShadow: `0 0 5px ${sc}` }} />
)}
<span className="svc-node-name" style={{ color: svc.color }}>{svc.label}</span>
<span className="svc-node-sub">{svc.sub}</span>
{result?.ms != null && <span className="svc-node-ms">{result.ms}ms</span>}
</button>
);
}
// ─── Service Popup ────────────────────────────────────────────────────────────
function ServicePopup({ id, result, onClose }) {
const svc = SERVICE_META[id];
const sc = statusColor(result?.status);
const [helpTitle, setHelpTitle] = React.useState(null);
const [helpBody, setHelpBody] = React.useState(null);
const [helpLoading, setHelpLoading] = React.useState(true);
React.useEffect(() => {
setHelpLoading(true);
fetch(`${HELP_BASE}/service.${id}`)
.then(r => r.json())
.then(data => {
setHelpTitle(data.title || svc.label);
setHelpBody(data.body || '<p>No information available for this topic yet.</p>');
})
.catch(() => {
setHelpBody('<p>No information available for this topic yet.</p>');
})
.finally(() => setHelpLoading(false));
}, [id]);
const handleBackdrop = (e) => { if (e.target === e.currentTarget) onClose(); };
return (
<div className="svc-popup-backdrop" onClick={handleBackdrop}>
<div className="svc-popup">
<div className="svc-popup-bar" style={{ background: svc.color }} />
<div className="svc-popup-head">
<div>
<div className="svc-popup-title" style={{ color: svc.color }}>{svc.label}</div>
<div className="svc-popup-sub">{svc.sub}</div>
</div>
<button className="svc-popup-close" onClick={onClose}></button>
</div>
{/* Status row */}
<div className="svc-popup-status-row">
<div className="svc-popup-stat">
<div className="svc-popup-stat-label">STATUS</div>
<div className="svc-popup-stat-value" style={{ color: result ? sc : '#94a3b8' }}>
{result && <span className="svc-popup-dot" style={{ background: sc, boxShadow: `0 0 4px ${sc}` }} />}
{statusLabel(result?.status)}
</div>
</div>
{result?.ms != null && (
<div className="svc-popup-stat">
<div className="svc-popup-stat-label">RESPONSE</div>
<div className="svc-popup-stat-value">{result.ms}ms</div>
</div>
)}
<div className="svc-popup-stat">
<div className="svc-popup-stat-label">TIER</div>
<div className="svc-popup-stat-value" style={{ color: '#64748b' }}>
{ svc.tier === 'public' ? 'Public' : svc.tier === 'registration' ? 'Registration' : 'Internal' }
</div>
</div>
</div>
{/* Health endpoint */}
{svc.mode !== 'none' && (
<div className="svc-popup-section">
<div className="svc-popup-section-label">HEALTH ENDPOINT</div>
<code className="svc-popup-code">
{svc.mode === 'direct'
? svc.url
: `POST ${API_BASE}/api/execution/request → provider: "${svc.provider}" → Ping`}
</code>
</div>
)}
{/* About — from help DB */}
<div className="svc-popup-section">
<div className="svc-popup-section-label">ABOUT</div>
{helpLoading
? <p className="svc-popup-loading">Loading</p>
: <div
className="svc-popup-about"
dangerouslySetInnerHTML={{ __html: helpBody }}
/>
}
</div>
{/* Stack */}
<div className="svc-popup-section">
<div className="svc-popup-section-label">STACK</div>
<div className="svc-popup-tech">{svc.tech}</div>
</div>
{/* Error detail */}
{result?.message && (
<div className="svc-popup-section">
<div className="svc-popup-section-label">DETAIL</div>
<code className="svc-popup-code svc-popup-error">{result.message}</code>
</div>
)}
</div>
</div>
);
}
// ─── Architecture Diagram ─────────────────────────────────────────────────────
function ArchDiagram({ results, onNodeClick }) {
return (
<div className="arch2-wrap">
<div className="arch2-title">Platform Architecture</div>
<div className="arch2-top">
{/* Public tier */}
<div className="arch2-tier" style={{ flex: 1 }}>
<div className="arch2-tier-label">Public Access</div>
<div className="arch2-tier-nodes">
{TIER_PUBLIC.map(id => (
<ServiceNode key={id} id={id} result={results[id]} onClick={onNodeClick} />
))}
</div>
<div className="arch2-db-connector">
<div className="arch2-vline" />
<div className="arch2-db">🗄 SQL DB</div>
</div>
</div>
<div className="arch2-pillar-sep" />
{/* Registration tier */}
<div className="arch2-tier arch2-tier-reg">
<div className="arch2-tier-label">Registration</div>
<div className="arch2-tier-nodes">
{TIER_REG.map(id => (
<ServiceNode key={id} id={id} result={results[id]} onClick={onNodeClick} />
))}
</div>
<div className="arch2-db-connector">
<div className="arch2-vline" />
<div className="arch2-db">🗄 Reg DB</div>
</div>
</div>
</div>
{/* Arrow */}
<div className="arch2-arrow-zone">
<div className="arch2-arrow-line">
<span className="arch2-arrow-label">internal routing via Gateway</span>
</div>
</div>
{/* Internal tier */}
<div className="arch2-tier arch2-tier-internal">
<div className="arch2-tier-label">Internal Only Azure Container Apps</div>
<div className="arch2-tier-nodes arch2-tier-nodes-center">
{TIER_INTERNAL.map(id => (
<ServiceNode key={id} id={id} result={results[id]} onClick={onNodeClick} />
))}
</div>
</div>
{/* Legend */}
<div className="arch2-legend">
{[['#22c55e','Healthy'],['#f59e0b','Error'],['#ef4444','Unreachable'],['#cbd5e1','Not checked']].map(([c,l]) => (
<span key={l} className="arch2-legend-item">
<span className="arch2-legend-dot" style={{ background: c }} />{l}
</span>
))}
</div>
</div>
);
}
// ─── Main Dashboard ───────────────────────────────────────────────────────────
export default function HealthDashboard() {
const { getIdToken } = useAuth();
const [results, setResults] = useState({});
const [checking, setChecking] = useState(false);
const [lastCheck, setLastCheck] = useState(null);
const [popup, setPopup] = useState(null);
const checkAll = useCallback(async () => {
setChecking(true);
const next = {};
const token = await getIdToken();
await Promise.all(
CHECKABLE.map(async (id) => {
const svc = SERVICE_META[id];
const start = performance.now();
try {
let resp;
if (svc.mode === 'direct') {
resp = await fetch(svc.url, { signal: AbortSignal.timeout(8000) });
} else {
resp = await fetch(`${API_BASE}/api/execution/request`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({ provider: svc.provider, operation: 'Ping', payload: {} }),
signal: AbortSignal.timeout(8000)
});
}
const ms = Math.round(performance.now() - start);
if (resp.ok) {
let data = null;
try { data = await resp.json(); } catch {}
next[id] = { status: 'healthy', ms, data, httpStatus: resp.status };
} else {
let text = '';
try { text = await resp.text(); } catch {}
next[id] = { status: 'error', ms, httpStatus: resp.status, message: `HTTP ${resp.status}: ${text.substring(0, 200)}` };
}
} catch (err) {
const ms = Math.round(performance.now() - start);
next[id] = { status: 'unreachable', ms, message: err.name === 'TimeoutError' ? 'Timed out (8s)' : err.message };
}
})
);
setResults(next);
setLastCheck(new Date());
setChecking(false);
}, [getIdToken]);
const healthyCount = Object.values(results).filter(r => r.status === 'healthy').length;
const totalChecked = Object.keys(results).length;
return (
<div className="health-dashboard">
<div className="health-header">
<div className="health-title-row">
<h3 className="health-title"> Service Health</h3>
{totalChecked > 0 && (
<span className={`health-summary ${healthyCount === totalChecked ? 'all-healthy' : 'has-issues'}`}>
{healthyCount}/{totalChecked} services up
</span>
)}
</div>
<div className="health-actions">
{lastCheck && (
<span className="health-timestamp">Last check: {lastCheck.toLocaleTimeString()}</span>
)}
<button
className="token-btn token-btn-primary"
onClick={checkAll}
disabled={checking}
style={{ minWidth: 120 }}
>
{checking ? '⟳ Checking…' : '▶ Check All'}
</button>
</div>
</div>
{totalChecked === 0 && (
<p className="arch2-hint">Click any node for details · Run Check All to test live status</p>
)}
<ArchDiagram results={results} onNodeClick={setPopup} />
{popup && (
<ServicePopup
id={popup}
result={results[popup]}
onClose={() => setPopup(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,563 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useAuth } from '../auth/AuthProvider';
import { MGMT_BASE } from '../auth/authConfig';
// ── Icon helpers ──────────────────────────────────────────────────────────────
const Icon = ({ d, size = 14 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d={d} />
</svg>
);
const IcPlus = () => <Icon d="M12 5v14M5 12h14" />;
const IcEdit = () => <Icon d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />;
const IcTrash = () => <Icon d="M3 6h18M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6M10 11v6M14 11v6M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />;
const IcRefresh = () => <Icon d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />;
const IcX = () => <Icon d="M18 6L6 18M6 6l12 12" size={12} />;
// ── Module colour map ─────────────────────────────────────────────────────────
const MODULE_COLORS = {
admin: { bg: '#eff6ff', border: '#bfdbfe', text: '#1d4ed8' },
client: { bg: '#f0fdf4', border: '#bbf7d0', text: '#15803d' },
service: { bg: '#fdf4ff', border: '#e9d5ff', text: '#7e22ce' },
};
const defaultModuleColor = { bg: '#f8fafc', border: '#e2e8f0', text: '#475569' };
const moduleColor = (mod) => MODULE_COLORS[mod] || defaultModuleColor;
// ── Styled help-key chip ──────────────────────────────────────────────────────
function HelpKey({ helpKey }) {
const parts = helpKey.split('.');
const mod = parts[0] || '';
const col = moduleColor(mod);
return (
<span style={{ display: 'inline-flex', alignItems: 'center', fontFamily: 'monospace', fontSize: 12 }}>
<span style={{
padding: '2px 7px', borderRadius: '4px 0 0 4px',
background: col.bg, border: `1px solid ${col.border}`,
color: col.text, fontWeight: 700, fontSize: 11,
borderRight: 'none',
}}>{mod}</span>
{parts.slice(1).map((seg, i) => {
const isLast = i === parts.slice(1).length - 1;
return (
<span key={i} style={{
padding: '2px 0',
borderTop: '1px solid #e2e8f0', borderBottom: '1px solid #e2e8f0',
borderRight: isLast ? '1px solid #e2e8f0' : 'none',
borderRadius: isLast ? '0 4px 4px 0' : 0,
background: '#fff', color: isLast ? '#0f172a' : '#64748b',
fontWeight: isLast ? 600 : 400,
}}>
<span style={{ color: '#cbd5e1', padding: '0 3px 0 4px' }}>.</span>{seg}
</span>
);
})}
</span>
);
}
// ── Status dot ────────────────────────────────────────────────────────────────
function StatusDot({ active }) {
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<span style={{
width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
background: active ? '#22c55e' : '#d1d5db',
}} />
<span style={{ fontSize: 12, color: active ? '#15803d' : '#9ca3af', fontWeight: 500 }}>
{active ? 'Active' : 'Inactive'}
</span>
</span>
);
}
// ── Icon button ───────────────────────────────────────────────────────────────
function IconBtn({ onClick, disabled, title, danger, children }) {
const [hover, setHover] = useState(false);
return (
<button onClick={onClick} disabled={disabled} title={title}
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '5px 10px', borderRadius: 5, border: '1px solid',
fontSize: 12, fontWeight: 500, cursor: disabled ? 'not-allowed' : 'pointer',
fontFamily: 'inherit', transition: 'all 0.12s', opacity: disabled ? 0.45 : 1,
background: danger ? (hover ? '#fef2f2' : '#fff') : (hover ? '#eff6ff' : '#fff'),
borderColor: danger ? (hover ? '#fca5a5' : '#e2e8f0') : (hover ? '#bfdbfe' : '#e2e8f0'),
color: danger ? (hover ? '#dc2626' : '#94a3b8') : (hover ? '#2563eb' : '#64748b'),
}}>
{children}
</button>
);
}
export default function HelpPanel() {
const { getAccessToken } = useAuth();
const apiCall = useCallback(async (path, method = 'GET', body = null) => {
const token = await getAccessToken();
if (!token) return { ok: false, error: 'Not authenticated' };
const opts = { method, headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } };
if (body) opts.body = JSON.stringify(body);
try {
const res = await fetch(`${MGMT_BASE}${path}`, opts);
const text = await res.text();
try { return JSON.parse(text); } catch { return { ok: false, error: text }; }
} catch (e) { return { ok: false, error: e.message }; }
}, [getAccessToken]);
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [toast, setToast] = useState(null);
const [showInactive, setShowInactive] = useState(false);
const [filterModule, setFilterModule] = useState('');
const [filterSection, setFilterSection] = useState('');
const [editKey, setEditKey] = useState(null);
const [form, setForm] = useState({ helpKey: '', title: '', body: '', isActive: true });
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const bodyRef = useRef(null);
const showToast = useCallback((msg, type = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
}, []);
const loadItems = useCallback(async () => {
setLoading(true); setError(null);
try {
const res = await apiCall('/api/admin/help/list', 'POST', { includeInactive: showInactive });
if (res.ok) setItems(res.items || []);
else setError(res.error || 'Failed to load help content');
} catch { setError('Network error loading help content'); }
finally { setLoading(false); }
}, [apiCall, showInactive]);
useEffect(() => { loadItems(); }, [loadItems]);
const { modules, sections } = useMemo(() => {
const mods = new Set(), secs = new Set();
items.forEach(({ helpKey }) => {
const p = helpKey.split('.');
if (p[0]) mods.add(p[0]);
if (p[1]) secs.add(p[1]);
});
return { modules: [...mods].sort(), sections: [...secs].sort() };
}, [items]);
const handleModuleChange = (val) => { setFilterModule(val); setFilterSection(''); };
const availableSections = useMemo(() => {
if (!filterModule) return sections;
const secs = new Set();
items.forEach(({ helpKey }) => {
const p = helpKey.split('.');
if (p[0] === filterModule && p[1]) secs.add(p[1]);
});
return [...secs].sort();
}, [items, filterModule, sections]);
const visibleItems = useMemo(() => items.filter(({ helpKey }) => {
const p = helpKey.split('.');
if (filterModule && p[0] !== filterModule) return false;
if (filterSection && p[1] !== filterSection) return false;
return true;
}), [items, filterModule, filterSection]);
const hasFilter = filterModule || filterSection;
const startEdit = (item) => { setEditKey(item.helpKey); setForm({ helpKey: item.helpKey, title: item.title, body: item.body, isActive: item.isActive }); };
const startNew = () => { setEditKey('new'); setForm({ helpKey: '', title: '', body: '', isActive: true }); };
const cancelEdit = () => { setEditKey(null); setForm({ helpKey: '', title: '', body: '', isActive: true }); };
const handleSave = async () => {
if (!form.helpKey.trim()) return showToast('Help key is required', 'error');
if (!form.title.trim()) return showToast('Title is required', 'error');
if (!form.body.trim()) return showToast('Body content is required', 'error');
setSaving(true);
try {
const res = await apiCall('/api/admin/help', 'POST', {
helpKey: form.helpKey.trim().toLowerCase(), title: form.title.trim(),
body: form.body.trim(), isActive: form.isActive,
});
if (res.ok) { showToast(`Saved "${form.helpKey}"`); cancelEdit(); loadItems(); }
else showToast(res.error || 'Save failed', 'error');
} catch { showToast('Network error saving', 'error'); }
finally { setSaving(false); }
};
const handleDelete = async (helpKey) => {
setDeleting(helpKey);
try {
const res = await apiCall(`/api/admin/help/${encodeURIComponent(helpKey)}`, 'DELETE');
if (res.ok) { showToast(`Deleted "${helpKey}"`); setConfirmDelete(null); loadItems(); }
else showToast(res.error || 'Delete failed', 'error');
} catch { showToast('Network error deleting', 'error'); }
finally { setDeleting(null); }
};
const KEY_SUGGESTIONS = [
'client.wizard.objective', 'client.wizard.audience', 'client.wizard.budget',
'client.wizard.channels', 'client.wizard.creative', 'client.wizard.review',
'admin.campaigns.status', 'admin.campaigns.pacing', 'admin.clients.approval', 'admin.clients.status',
];
const unusedSuggestions = KEY_SUGGESTIONS.filter(k => !items.find(i => i.helpKey === k));
const activeCount = items.filter(i => i.isActive).length;
const inactiveCount = items.length - activeCount;
const fmtDate = (d) => {
if (!d) return '—';
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' });
};
// ── Shared styles ─────────────────────────────────────────────────────────
const inputStyle = {
width: '100%', padding: '7px 11px', border: '1px solid #e2e8f0',
borderRadius: 6, fontSize: 13, fontFamily: 'inherit',
outline: 'none', boxSizing: 'border-box', color: '#1e293b',
};
const labelStyle = {
display: 'block', fontSize: 11, fontWeight: 700, color: '#64748b',
textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 5,
};
return (
<div style={{ maxWidth: 960 }}>
{/* ── Toast ─────────────────────────────────────────────────── */}
{toast && (
<div style={{
position: 'fixed', bottom: 24, right: 24, zIndex: 1000,
background: toast.type === 'error' ? '#dc2626' : '#16a34a',
color: '#fff', padding: '10px 18px', borderRadius: 8,
fontSize: 13, fontWeight: 500, boxShadow: '0 4px 16px rgba(0,0,0,0.18)',
}}>
{toast.msg}
</div>
)}
{/* ── Header ────────────────────────────────────────────────── */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, color: '#1e293b' }}>Help Content</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 4 }}>
<span style={{ fontSize: 13, color: '#64748b' }}>
{hasFilter ? `${visibleItems.length} of ${items.length} entries` : `${items.length} entries`}
</span>
{!loading && items.length > 0 && (<>
<span style={{ color: '#e2e8f0' }}>·</span>
<span style={{ fontSize: 12, color: '#22c55e', fontWeight: 500 }}>{activeCount} active</span>
{inactiveCount > 0 && (<>
<span style={{ color: '#e2e8f0' }}>·</span>
<span style={{ fontSize: 12, color: '#94a3b8' }}>{inactiveCount} inactive</span>
</>)}
</>)}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<label style={{
display: 'flex', alignItems: 'center', gap: 6,
fontSize: 13, color: '#64748b', cursor: 'pointer', userSelect: 'none',
}}>
<input type="checkbox" checked={showInactive}
onChange={e => setShowInactive(e.target.checked)} />
Show inactive
</label>
<button onClick={loadItems} disabled={loading} title="Refresh"
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 32, height: 32, borderRadius: 6, border: '1px solid #e2e8f0',
background: '#fff', cursor: 'pointer', color: '#64748b', flexShrink: 0,
}}>
<IcRefresh />
</button>
<button onClick={startNew}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '7px 14px', borderRadius: 6, border: 'none',
background: '#2563eb', color: '#fff',
fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
}}>
<IcPlus /> New Entry
</button>
</div>
</div>
{/* ── Filter pills ──────────────────────────────────────────── */}
{!loading && modules.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
{/* Module label + pills */}
<span style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Module</span>
{['', ...modules].map(m => {
const active = filterModule === m;
const col = m ? moduleColor(m) : defaultModuleColor;
return (
<button key={m || '__all'} onClick={() => handleModuleChange(m)}
style={{
padding: '4px 12px', borderRadius: 20, border: '1px solid',
fontSize: 12, fontWeight: active ? 600 : 400,
cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
background: active ? col.bg : '#fff',
borderColor: active ? col.border : '#e2e8f0',
color: active ? col.text : '#64748b',
}}>
{m || 'All'}
</button>
);
})}
{/* Section pills */}
{filterModule && availableSections.length > 0 && (<>
<span style={{ width: 1, height: 16, background: '#e2e8f0', flexShrink: 0 }} />
<span style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Section</span>
{availableSections.map(s => {
const active = filterSection === s;
return (
<button key={s} onClick={() => setFilterSection(active ? '' : s)}
style={{
padding: '3px 10px', borderRadius: 20, border: '1px solid',
fontSize: 11, fontWeight: active ? 600 : 400, fontFamily: 'monospace',
cursor: 'pointer', transition: 'all 0.12s',
background: active ? '#0f172a' : '#fff',
borderColor: active ? '#0f172a' : '#e2e8f0',
color: active ? '#fff' : '#64748b',
}}>
{s}
</button>
);
})}
</>)}
{hasFilter && (
<button onClick={() => { setFilterModule(''); setFilterSection(''); }}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '3px 9px', borderRadius: 20,
border: '1px solid #e2e8f0', background: '#f8fafc',
fontSize: 11, color: '#64748b', cursor: 'pointer', fontFamily: 'inherit',
}}>
<IcX /> Clear
</button>
)}
</div>
)}
{/* ── Edit / new form ───────────────────────────────────────── */}
{editKey && (
<div style={{
background: '#fff', border: '1px solid #bfdbfe', borderRadius: 10,
padding: 20, marginBottom: 20, boxShadow: '0 0 0 3px rgba(59,130,246,0.06)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18 }}>
<div style={{ fontWeight: 600, fontSize: 14, color: '#1e293b' }}>
{editKey === 'new' ? '✦ New Help Entry' : `Editing: ${editKey}`}
</div>
<button onClick={cancelEdit}
style={{
padding: '5px 12px', borderRadius: 6, border: '1px solid #e2e8f0',
background: '#fff', fontSize: 12, color: '#64748b',
cursor: 'pointer', fontFamily: 'inherit',
}}>
Cancel
</button>
</div>
{/* Help key */}
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>
Help Key <span style={{ color: '#94a3b8', fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}> lowercase, dot-separated</span>
</label>
{editKey === 'new' ? (
<div>
<input style={inputStyle} placeholder="e.g. client.wizard.budget"
value={form.helpKey}
onChange={e => setForm(f => ({ ...f, helpKey: e.target.value.toLowerCase() }))} />
{unusedSuggestions.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 5, marginTop: 7 }}>
<span style={{ fontSize: 11, color: '#94a3b8' }}>Suggested:</span>
{unusedSuggestions.slice(0, 5).map(k => (
<button key={k} onClick={() => setForm(f => ({ ...f, helpKey: k }))}
style={{
padding: '2px 8px', borderRadius: 4, border: '1px solid #bfdbfe',
background: '#eff6ff', color: '#2563eb', fontSize: 11,
fontFamily: 'monospace', cursor: 'pointer',
}}>
{k}
</button>
))}
</div>
)}
</div>
) : (
<div style={{
padding: '7px 11px', background: '#f8fafc', border: '1px solid #e2e8f0',
borderRadius: 6, fontFamily: 'monospace', fontSize: 13, color: '#475569',
}}>
{form.helpKey}
</div>
)}
</div>
{/* Title + Active toggle */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 14, alignItems: 'end', marginBottom: 14 }}>
<div>
<label style={labelStyle}>Title</label>
<input style={inputStyle} placeholder="Modal heading shown to users"
value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} />
</div>
<label style={{
display: 'flex', alignItems: 'center', gap: 6, paddingBottom: 8,
fontSize: 13, color: '#475569', cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap',
}}>
<input type="checkbox" checked={form.isActive}
onChange={e => setForm(f => ({ ...f, isActive: e.target.checked }))} />
Active
</label>
</div>
{/* Body */}
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>
Body
<span style={{ color: '#94a3b8', fontWeight: 400, textTransform: 'none', letterSpacing: 0, marginLeft: 6 }}>
&lt;p&gt; &lt;ul&gt; &lt;li&gt; &lt;strong&gt; &lt;h4&gt; render in popup
</span>
</label>
<textarea ref={bodyRef} style={{
...inputStyle, resize: 'vertical', minHeight: 160,
fontFamily: "'SF Mono','Menlo','Monaco','Consolas',monospace",
fontSize: 12, lineHeight: 1.6,
}}
placeholder="<p>Your help text here…</p>" value={form.body}
onChange={e => setForm(f => ({ ...f, body: e.target.value }))} rows={8} />
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button onClick={handleSave} disabled={saving}
style={{
padding: '7px 20px', borderRadius: 6, border: 'none',
background: saving ? '#93c5fd' : '#2563eb', color: '#fff',
fontSize: 13, fontWeight: 500, cursor: saving ? 'not-allowed' : 'pointer',
fontFamily: 'inherit',
}}>
{saving ? 'Saving…' : 'Save Entry'}
</button>
</div>
</div>
)}
{/* ── Loading / error / empty ───────────────────────────────── */}
{loading && (
<div style={{ padding: '40px 0', textAlign: 'center', color: '#94a3b8', fontSize: 13 }}>
Loading help content
</div>
)}
{error && !loading && (
<div style={{
padding: '12px 16px', background: '#fef2f2', border: '1px solid #fecaca',
borderRadius: 8, color: '#dc2626', fontSize: 13, marginBottom: 16,
}}>
{error}
</div>
)}
{!loading && !error && items.length === 0 && (
<div style={{ textAlign: 'center', padding: '60px 20px', color: '#94a3b8' }}>
<div style={{ fontSize: 32, marginBottom: 12 }}>📝</div>
<div style={{ fontWeight: 600, color: '#475569', marginBottom: 6 }}>No help entries yet</div>
<div style={{ fontSize: 13 }}>Click <strong>New Entry</strong> to create the first one.</div>
</div>
)}
{/* ── Table ─────────────────────────────────────────────────── */}
{!loading && items.length > 0 && (
<div style={{ background: '#fff', border: '1px solid #e5e7eb', borderRadius: 10, overflow: 'hidden' }}>
{visibleItems.length === 0 ? (
<div style={{ padding: '32px 20px', textAlign: 'center', color: '#94a3b8', fontSize: 13 }}>
No entries match the current filter.
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f8fafc', borderBottom: '1px solid #e5e7eb' }}>
{[
{ label: 'Help Key', width: '38%' },
{ label: 'Title', width: null },
{ label: 'Status', width: 90 },
{ label: 'Updated', width: 80 },
{ label: '', width: 106 },
].map(({ label, width }) => (
<th key={label} style={{
padding: '10px 16px', textAlign: 'left',
fontSize: 11, fontWeight: 700, color: '#94a3b8',
letterSpacing: '0.05em', textTransform: 'uppercase',
width: width || undefined,
}}>
{label}
</th>
))}
</tr>
</thead>
<tbody>
{visibleItems.map((item, idx) => (
<tr key={item.helpKey} style={{
borderBottom: idx < visibleItems.length - 1 ? '1px solid #f1f5f9' : 'none',
opacity: !item.isActive ? 0.5 : 1,
background: editKey === item.helpKey ? '#f8fbff' : 'transparent',
}}>
<td style={{ padding: '11px 16px' }}>
<HelpKey helpKey={item.helpKey} />
</td>
<td style={{ padding: '11px 16px', fontSize: 13, color: '#374151' }}>
{item.title}
</td>
<td style={{ padding: '11px 16px' }}>
<StatusDot active={item.isActive} />
</td>
<td style={{ padding: '11px 16px', fontSize: 12, color: '#94a3b8', whiteSpace: 'nowrap' }}>
{fmtDate(item.updatedAt)}
</td>
<td style={{ padding: '8px 12px' }}>
{confirmDelete === item.helpKey ? (
<div style={{ display: 'flex', gap: 4 }}>
<button onClick={() => handleDelete(item.helpKey)}
disabled={deleting === item.helpKey}
style={{
padding: '4px 10px', borderRadius: 5, border: '1px solid #fca5a5',
background: '#fef2f2', color: '#dc2626',
fontSize: 11, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}>
{deleting === item.helpKey ? '…' : 'Confirm'}
</button>
<button onClick={() => setConfirmDelete(null)}
style={{
padding: '4px 8px', borderRadius: 5, border: '1px solid #e2e8f0',
background: '#fff', color: '#94a3b8',
fontSize: 12, cursor: 'pointer',
}}>
</button>
</div>
) : (
<div style={{ display: 'flex', gap: 5 }}>
<IconBtn onClick={() => startEdit(item)} disabled={editKey === item.helpKey} title="Edit">
<IcEdit /> Edit
</IconBtn>
<IconBtn onClick={() => setConfirmDelete(item.helpKey)} danger title="Delete">
<IcTrash />
</IconBtn>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
);
}

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,169 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useAuth } from '../auth/AuthProvider';
import { getServices, getService } from '../services/serviceCatalog';
import { callApi } from '../services/apiClient';
import CreativePreview from './CreativePreview';
import AudiencePreview from './AudiencePreview';
// localStorage key for persisting tenant ID
const TENANT_STORAGE_KEY = 'adp_tenantId';
export default function ServiceForm({ initialProvider }) {
const { sessionUser, getIdToken, getAccessToken } = useAuth();
const providerId = initialProvider || 'gateway';
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 - 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]);
// Reset service selection + sample 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));
}
setResp(null);
}, [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 token = await getIdToken();
const entraToken = await getAccessToken();
const authConfig = {
sessionToken: token, // Gateway + provider calls
entraToken: entraToken, // Management / staff-plane calls
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);
}
};
// Tenant ID placeholder varies by provider
const tenantPlaceholder = {
google: 'Google Ads Customer ID (e.g. 1234567890)',
meta: 'Meta Ad Account ID (e.g. act_123456789)',
tiktok: 'TikTok Advertiser ID (e.g. 7123456789012345678)',
creative: 'Not required for Creative',
gateway: 'Not required for Gateway direct calls',
}[providerId] || 'Account ID (optional)';
const showTenant = providerId !== 'gateway';
return (
<div className="service-form">
{/* Tenant Configuration */}
{showTenant && (
<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={tenantPlaceholder}
/>
</div>
</fieldset>
)}
{/* Operation selector */}
<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>
{/* Routing indicator for provider calls */}
{currentService && !currentService.endpoint && (
<div className="row routing-info">
<span className="route-badge">
<strong>Route:</strong> Gateway {providerId} {currentService.service}.{currentService.action}
</span>
</div>
)}
{/* Endpoint indicator for direct gateway calls */}
{currentService?.endpoint && (
<div className="row routing-info">
<span className="route-badge route-direct">
<strong>Direct:</strong> {currentService.method} {currentService.endpoint}
</span>
</div>
)}
<div className="row">
<label>Payload (JSON)</label>
<textarea rows={10} value={input} onChange={(e) => setInput(e.target.value)} />
</div>
<button className="submit-btn" onClick={submit} disabled={!serviceId || isBusy}>
{isBusy ? 'Calling…' : 'Submit'}
</button>
{resp && (
<div className="response-section">
{/* Status badge */}
<div className={`response-status ${resp.ok !== false ? 'status-ok' : 'status-fail'}`}>
{resp.ok !== false ? '✓ Success' : '✗ Error'}
</div>
{/* Visual Preview for Creative responses */}
{providerId === 'creative' && resp.ok && (
<CreativePreview data={resp} />
)}
{/* Visual Preview for Audience responses */}
{providerId === 'google' && currentService?.service === 'audience' && resp.ok && (
<AudiencePreview data={resp} />
)}
{/* Raw JSON response */}
<details className="json-details" open={
(providerId !== 'creative' && currentService?.service !== 'audience') || !resp.ok
}>
<summary>Raw JSON Response</summary>
<pre className="response">{JSON.stringify(resp, null, 2)}</pre>
</details>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,128 @@
import React, { useState } from 'react';
import { useAuth } from '../auth/AuthProvider';
import ApiTestingPanel from './ApiTestingPanel';
import DocumentsPanel from './DocumentsPanel';
import HealthDashboard from './HealthDashboard';
import ActivityPanel from './ActivityPanel';
import HelpContentPanel from './HelpContentPanel';
const NAV_SECTIONS = [
{ type: 'label', text: 'MONITORING' },
{ id: 'health', label: 'Service Health', icon: '⚡' },
{ type: 'label', text: 'RESOURCES' },
{ id: 'documents', label: 'Documents', icon: '📁' },
{ type: 'label', text: 'DEVELOPMENT' },
{ id: 'api-testing', label: 'API Testing', icon: '🔧' },
{ type: 'label', text: 'ADMINISTRATION' },
{ id: 'activity', label: 'Activity Log', icon: '📋' },
{ id: 'help-content', label: 'Help Content', icon: '💬' },
];
export default function Shell() {
const { sessionUser, signOut } = useAuth();
const [activeNav, setActiveNav] = useState('health');
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const renderContent = () => {
switch (activeNav) {
case 'health': return <HealthDashboard />;
case 'documents': return <DocumentsPanel />;
case 'api-testing': return <ApiTestingPanel />;
case 'activity': return <ActivityPanel />;
case 'help-content': return <HelpContentPanel />;
default: return <HealthDashboard />;
}
};
const getPageTitle = () => {
const item = NAV_SECTIONS.find(n => n.id === activeNav);
return item?.label || 'Service Health';
};
return (
<div className={`app-shell ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
{/* Sidebar */}
<aside className="sidebar">
<div className="sidebar-header">
<div className="sidebar-brand">
{!sidebarCollapsed && <span className="brand-text">AdPlatform</span>}
<span className="brand-badge">DEV</span>
</div>
<button
className="sidebar-toggle"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
title={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{sidebarCollapsed ? '▸' : '◂'}
</button>
</div>
<nav className="sidebar-nav">
{NAV_SECTIONS.map((item, i) => {
if (item.type === 'label') {
return !sidebarCollapsed
? <div key={i} className="nav-section-label">{item.text}</div>
: <div key={i} className="nav-section-divider" />;
}
const isActive = activeNav === item.id;
return (
<button
key={item.id}
className={`nav-item ${isActive ? 'nav-active' : ''}`}
onClick={() => setActiveNav(item.id)}
title={sidebarCollapsed ? item.label : undefined}
>
<span className="nav-icon">{item.icon}</span>
{!sidebarCollapsed && <span className="nav-label">{item.label}</span>}
</button>
);
})}
</nav>
<div className="sidebar-footer">
<div className="sidebar-user">
<span className="user-avatar">
{(sessionUser?.name || sessionUser?.email || 'U')[0].toUpperCase()}
</span>
{!sidebarCollapsed && (
<div className="user-details">
<span className="user-name-text">
{sessionUser?.name || sessionUser?.email || 'User'}
</span>
{sessionUser?.clientId && (
<span className="user-client">Client: {sessionUser.clientId}</span>
)}
</div>
)}
</div>
{!sidebarCollapsed && (
<button className="signout-btn" onClick={signOut}>
Sign Out
</button>
)}
</div>
</aside>
{/* Main Content */}
<main className="main-content">
<header className="content-header">
<h2 className="page-title">{getPageTitle()}</h2>
<div className="session-info">
<span className="session-badge">
<span className="session-dot" />
Session Active
</span>
</div>
</header>
<div className="content-body">
{renderContent()}
</div>
</main>
</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 Tech</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-Tech/src/index.js Normal file
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,148 @@
// src/services/apiClient.js
/**
* Centralized API client
*
* Auth contract:
* - POST /api/auth/session → Entra JWT (handled by AuthProvider)
* - Gateway + provider calls → session token (Bearer <sessionToken>)
* - Management / staff-plane calls → Entra JWT (Bearer <entraToken>)
* - Registration health check → anonymous (no auth header needed)
*
* Token selection is automatic — callers always supply both tokens via authConfig;
* the client picks the correct one based on the target baseUrl.
*
* Routing:
* - Services with explicit endpoint → call baseUrl+endpoint directly
* - Provider ops (google, creative…) → POST /api/execution/request
*/
import { getService } from './serviceCatalog';
import { API_BASE, MGMT_BASE } from '../auth/authConfig';
// Domains that require a staff Entra JWT rather than a Gateway session token
const STAFF_PLANE_ORIGINS = [MGMT_BASE].map(u => new URL(u).origin);
function isStaffPlane(baseUrl) {
if (!baseUrl) return false;
try {
return STAFF_PLANE_ORIGINS.includes(new URL(baseUrl).origin);
} catch {
return false;
}
}
export async function callApi(providerId, serviceId, inputJson, authConfig) {
const { sessionToken, entraToken, tenantId } = authConfig || {};
// Look up the service definition from catalog
const serviceDef = getService(providerId, serviceId);
if (!serviceDef) {
throw new Error(`Unknown service: ${providerId}/${serviceId}`);
}
// Parse the input payload
let payload;
try {
payload = typeof inputJson === 'string' ? JSON.parse(inputJson) : inputJson;
} catch (e) {
throw new Error(`Invalid JSON payload: ${e.message}`);
}
// Build headers
const headers = { 'Content-Type': 'application/json' };
// Merge any custom headers from service definition (e.g. function key)
if (serviceDef.headers) {
Object.assign(headers, serviceDef.headers);
}
// ---- ROUTING ----
// Direct calls (have explicit endpoint)
if (serviceDef.endpoint) {
if (serviceDef.endpoint.startsWith('/api/auth/session')) {
// Session exchange — always Entra JWT
if (!entraToken) throw new Error('entraToken required for /api/auth/session');
headers['Authorization'] = `Bearer ${entraToken}`;
} else if (isStaffPlane(serviceDef.baseUrl)) {
// Management API — staff Entra JWT
if (entraToken) headers['Authorization'] = `Bearer ${entraToken}`;
} else {
// Gateway, Registration, or anything else — session token (or anonymous if none)
if (sessionToken) headers['Authorization'] = `Bearer ${sessionToken}`;
}
// Handle URL templating: replace {param} with values from payload
let endpoint = serviceDef.endpoint;
const urlParams = endpoint.match(/\{(\w+)\}/g) || [];
for (const param of urlParams) {
const paramName = param.slice(1, -1);
const value = payload[paramName];
if (value !== undefined) {
endpoint = endpoint.replace(param, encodeURIComponent(value));
delete payload[paramName];
}
}
// For GET/DELETE, convert remaining payload to query string
let url = `${serviceDef.baseUrl || API_BASE}${endpoint}`;
if ((serviceDef.method === 'GET' || serviceDef.method === 'DELETE') && Object.keys(payload).length > 0) {
const queryParams = new URLSearchParams();
for (const [key, value] of Object.entries(payload)) {
if (value !== undefined && value !== null) {
queryParams.append(key, value);
}
}
const qs = queryParams.toString();
if (qs) url += `?${qs}`;
}
const resp = await fetch(url, {
method: serviceDef.method || 'GET',
headers,
body: (serviceDef.method !== 'GET' && serviceDef.method !== 'DELETE')
? JSON.stringify(payload)
: undefined
});
return handleResponse(resp);
}
// Provider execution calls → POST /api/execution/request
if (!sessionToken) {
throw new Error('sessionToken required for provider API calls');
}
headers['Authorization'] = `Bearer ${sessionToken}`;
headers['X-Requested-With'] = 'AdPlatform-Client';
const execBody = {
provider: providerId,
operation: serviceId,
tenantId: tenantId || undefined,
payload
};
const resp = await fetch(`${API_BASE}/api/execution/request`, {
method: 'POST',
headers,
body: JSON.stringify(execBody)
});
return handleResponse(resp);
}
async function handleResponse(resp) {
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,10 @@
export const providers = [
{ id: 'gateway', label: 'Gateway (direct)' },
{ id: 'google', label: 'Google Ads' },
{ id: 'meta', label: 'Meta Ads' },
{ id: 'tiktok', label: 'TikTok Ads' },
{ id: 'creative', label: 'Creative' },
{ id: 'intelligence', label: 'Intelligence API' },
{ id: 'registration', label: 'Registration' },
{ id: 'management', label: 'Management API' },
];

View File

@@ -0,0 +1,636 @@
// 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: 'System: Ping (SQL test)',
sample: {},
endpoint: '/api/test/ping',
method: 'GET'
},
// Campaign Wizard operations
{
id: 'WizardCreate',
service: 'wizard',
action: 'create',
label: 'Wizard: Create New',
sample: { name: 'My Test Campaign', url: 'https://example.com' },
endpoint: '/api/wizard',
method: 'POST'
},
{
id: 'WizardGet',
service: 'wizard',
action: 'get',
label: 'Wizard: Get by ID',
sample: { wizardId: 'abc123def456' },
endpoint: '/api/wizard/{wizardId}',
method: 'GET'
},
{
id: 'WizardList',
service: 'wizard',
action: 'list',
label: 'Wizard: List All',
sample: { status: 'draft' },
endpoint: '/api/wizard',
method: 'GET'
},
{
id: 'WizardUpdateStep',
service: 'wizard',
action: 'updateStep',
label: 'Wizard: Update Step 4 (Audience)',
sample: {
wizardId: 'abc123def456',
step: 4,
data: {
locations: [{ id: 9061285, name: 'Huntington Beach', type: 'City', radiusMiles: 25 }],
demographics: { ageRanges: ['AGE_25_34', 'AGE_35_44'], genders: ['MALE', 'FEMALE'] },
audiences: { affinity: [80001], inMarket: [90012] }
}
},
endpoint: '/api/wizard/{wizardId}/step/{step}',
method: 'PUT'
},
{
id: 'WizardGetSummary',
service: 'wizard',
action: 'getSummary',
label: 'Wizard: Get Summary (Review)',
sample: { wizardId: 'abc123def456' },
endpoint: '/api/wizard/{wizardId}/summary',
method: 'GET'
},
{
id: 'WizardSubmit',
service: 'wizard',
action: 'submit',
label: 'Wizard: Submit Campaign',
sample: { wizardId: 'abc123def456', network: 'google' },
endpoint: '/api/wizard/{wizardId}/submit',
method: 'POST'
},
{
id: 'WizardDelete',
service: 'wizard',
action: 'delete',
label: 'Wizard: Delete',
sample: { wizardId: 'abc123def456' },
endpoint: '/api/wizard/{wizardId}',
method: 'DELETE'
},
// Campaign Intelligence (Gateway → SQL stored proc)
// These endpoints are Gateway-side; they do NOT call IntelligenceApi.
// IntelligenceApi is the spend distribution engine (forecast/wizard flow only).
{
id: 'IntelligenceHealth',
service: 'intelligence',
action: 'health',
label: 'Intelligence: Campaign Health Overview',
sample: {},
endpoint: '/api/intelligence/health',
method: 'GET'
},
{
id: 'IntelligencePacing',
service: 'intelligence',
action: 'pacing',
label: 'Intelligence: Budget Pacing',
sample: { initiativeId: 1 },
endpoint: '/api/intelligence/{initiativeId}/pacing',
method: 'GET'
},
{
id: 'IntelligenceReport',
service: 'intelligence',
action: 'report',
label: 'Intelligence: Post-Campaign Report',
sample: { initiativeId: 1 },
endpoint: '/api/intelligence/{initiativeId}/report',
method: 'GET'
},
{
id: 'IntelligenceSnapshot',
service: 'intelligence',
action: 'snapshot',
label: 'Intelligence: Record Metric Snapshot (Admin)',
sample: {
channelCampaignId: 1,
date: '2026-03-06',
impressions: 4200,
clicks: 87,
spend: 42.50,
conversions: 3
},
endpoint: '/api/intelligence/snapshot',
method: 'POST'
},
{
id: 'IntelligenceSnapshotBatch',
service: 'intelligence',
action: 'snapshotBatch',
label: 'Intelligence: Batch Metric Snapshots (Admin)',
sample: {
snapshots: [
{ channelCampaignId: 1, date: '2026-03-06', impressions: 4200, clicks: 87, spend: 42.50, conversions: 3 },
{ channelCampaignId: 2, date: '2026-03-06', impressions: 1800, clicks: 34, spend: 18.20, conversions: 1 }
]
},
endpoint: '/api/intelligence/snapshot/batch',
method: 'POST'
}
],
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: 'ListAccessibleCustomers',
service: 'accounts',
action: 'list',
label: 'Accounts: List Accessible Customers',
sample: {}
},
{
id: 'CreateCustomerClient',
service: 'accounts',
action: 'createClient',
label: 'Accounts: Create Sub-Account (under MCC)',
sample: { accountName: 'AdPlatform Test Account', currencyCode: 'USD', timeZone: 'America/Los_Angeles' }
},
// Audience targeting service
{
id: 'GetAudienceSegments',
service: 'audience',
action: 'getSegments',
label: 'Audience: Get All Segments',
sample: {}
},
{
id: 'SearchGeoTargets',
service: 'audience',
action: 'searchGeo',
label: 'Audience: Search Locations',
sample: { query: 'Huntington Beach', countryCode: 'US', maxResults: 10 }
}
],
meta: [
// System
{
id: 'Ping',
service: 'system',
action: 'ping',
label: 'System: Ping (MetaApi round trip)',
sample: {}
},
// Campaigns
{
id: 'CreateCampaign',
service: 'campaigns',
action: 'create',
label: 'Campaigns: Create',
sample: { name: 'Test Meta Campaign', objective: 'Traffic', status: 'Paused' }
},
{
id: 'ListCampaigns',
service: 'campaigns',
action: 'list',
label: 'Campaigns: List',
sample: { limit: 50 }
},
{
id: 'GetCampaign',
service: 'campaigns',
action: 'get',
label: 'Campaigns: Get by ID',
sample: { campaignId: '123456789' }
},
// Insights
{
id: 'GetCampaignInsights',
service: 'reporting',
action: 'campaignInsights',
label: 'Reporting: Campaign Insights',
sample: { campaignId: '123456789', datePreset: 'last_7d' }
},
{
id: 'GetAccountInsights',
service: 'reporting',
action: 'accountInsights',
label: 'Reporting: Account Insights',
sample: { datePreset: 'last_30d' }
},
// Accounts
{
id: 'CreateAdAccount',
service: 'accounts',
action: 'createAdAccount',
label: 'Accounts: Create Ad Account (under BM)',
sample: { name: 'Test Client Account', currency: 'USD', timezoneId: 1 }
},
{
id: 'ListAdAccounts',
service: 'accounts',
action: 'listAdAccounts',
label: 'Accounts: List BM Ad Accounts',
sample: {}
}
],
tiktok: [
// System
{
id: 'Ping',
service: 'system',
action: 'ping',
label: 'System: Ping (TikTokApi round trip)',
sample: {}
},
// Campaigns
{
id: 'CreateCampaign',
service: 'campaigns',
action: 'create',
label: 'Campaigns: Create',
sample: { name: 'Test TikTok Campaign', objective: 'Traffic', budgetMode: 'Day', budget: 50.00, status: 'Disable' }
},
{
id: 'ListCampaigns',
service: 'campaigns',
action: 'list',
label: 'Campaigns: List',
sample: { pageSize: 50, page: 1 }
},
{
id: 'GetCampaign',
service: 'campaigns',
action: 'get',
label: 'Campaigns: Get by ID',
sample: { campaignId: '1234567890123456789' }
},
// Reporting
{
id: 'GetReport',
service: 'reporting',
action: 'getReport',
label: 'Reporting: Integrated Report',
sample: { reportType: 'BASIC', dataLevel: 'AUCTION_CAMPAIGN', dimensions: ['campaign_id', 'stat_time_day'], metrics: ['spend', 'impressions', 'clicks', 'cpc', 'ctr'] }
},
// Accounts (Business Center)
{
id: 'CreateAdvertiser',
service: 'accounts',
action: 'createAdvertiser',
label: 'Accounts: Create Advertiser (under BC)',
sample: { name: 'Test Client', currency: 'USD', timezone: 'America/Los_Angeles', company: 'Test Company' }
},
{
id: 'ListAdvertisers',
service: 'accounts',
action: 'listAdvertisers',
label: 'Accounts: List BC Advertisers',
sample: {}
},
// Fund Management
{
id: 'TransferFunds',
service: 'finance',
action: 'transfer',
label: 'Finance: Transfer Funds (BC → Advertiser)',
sample: { advertiserId: '1234567890123456789', transferType: 'RECHARGE', amount: 100.00 }
}
],
creative: [
{
id: 'Ping',
service: 'system',
action: 'ping',
label: 'System: Ping (Creative round trip)',
sample: {}
},
{
id: 'AnalyzeUrl',
service: 'creative',
action: 'analyzeUrl',
label: 'Creative: Analyze URL',
sample: { url: 'https://example.com' }
},
{
id: 'GenerateAssets',
service: 'creative',
action: 'generateAssets',
label: 'Creative: Generate Assets from Analysis',
sample: {
analysis: {
title: 'Example',
description: 'A sample site',
headings: ['Welcome'],
bodySnippet: 'We sell quality products.',
domain: 'example.com'
}
}
},
{
id: 'GetImages',
service: 'creative',
action: 'getImages',
label: 'Creative: Get Images from Analysis',
sample: {
analysis: {
url: 'https://example.com',
title: 'Example Business',
metaDescription: 'Quality products and services',
headings: ['Welcome', 'Our Services'],
bodySnippet: 'We provide excellent service.',
inferredCategory: 'Business Services'
}
}
},
{
id: 'CreateDraft',
service: 'creative',
action: 'createDraft',
label: 'Creative: Full Pipeline (URL → Draft with Images)',
sample: { url: 'https://example.com' }
}
],
intelligence: [
// System
{
id: 'Ping',
service: 'system',
action: 'ping',
label: 'System: Ping (IntelligenceApi round trip)',
sample: {}
},
// Spend Distribution — the core engine operation.
// Gateway injects clientCategory from ClientContext before forwarding.
// Engines: General (default) | Franchisee | Franchisor (+ sub-category fallbacks)
{
id: 'SpendDistribution',
service: 'spendDistribution',
action: 'recommend',
label: 'Spend Distribution: Recommend (General)',
sample: {
clientCategory: 'General',
objective: 'traffic',
businessCategory: 'retail',
keywords: ['shoes', 'sneakers', 'footwear'],
geoTargeting: {
zipCodes: ['92648'],
radiusMiles: 25
},
audience: {
ageMin: 25,
ageMax: 44,
genders: ['MALE', 'FEMALE'],
interests: ['fashion', 'sports']
},
monthlyBudget: 1500,
channels: ['google', 'meta', 'tiktok']
}
},
{
id: 'SpendDistributionFranchisee',
service: 'spendDistribution',
action: 'recommendFranchisee',
label: 'Spend Distribution: Recommend (Franchisee)',
sample: {
clientCategory: 'Franchisee',
objective: 'leads',
businessCategory: 'food',
keywords: ['pizza', 'delivery', 'takeout'],
geoTargeting: {
zipCodes: ['92648', '92649'],
radiusMiles: 10
},
audience: {
ageMin: 18,
ageMax: 54,
genders: ['MALE', 'FEMALE']
},
monthlyBudget: 800,
channels: ['google', 'meta']
}
},
{
id: 'SpendDistributionFranchisor',
service: 'spendDistribution',
action: 'recommendFranchisor',
label: 'Spend Distribution: Recommend (Franchisor)',
sample: {
clientCategory: 'Franchisor',
objective: 'awareness',
businessCategory: 'food',
keywords: ['franchise', 'pizza chain', 'national brand'],
geoTargeting: {
zipCodes: ['90210', '10001', '60601'],
radiusMiles: 50
},
audience: {
ageMin: 30,
ageMax: 55,
genders: ['MALE', 'FEMALE'],
interests: ['business', 'investment', 'entrepreneurship']
},
monthlyBudget: 15000,
channels: ['google', 'meta', 'tiktok']
}
}
],
registration: [
// Health check — hits Registration Function directly (anonymous endpoint)
{
id: 'Health',
service: 'system',
action: 'health',
label: 'System: Health Check',
sample: {},
endpoint: '/api/health',
method: 'GET',
baseUrl: 'https://adpregapi.usimdev.com'
},
// ── Admin ops below route through Management proxy (/api/registration/*).
// Management validates the Entra JWT, then forwards to Registration Function
// using its own server-side function key. No client-side key required.
{
id: 'GetPending',
service: 'registration',
action: 'pending',
label: 'Registration: List Pending',
sample: {},
endpoint: '/api/registration/pending',
method: 'GET',
baseUrl: 'https://adpmgmt.usimdev.com'
},
{
id: 'GetById',
service: 'registration',
action: 'get',
label: 'Registration: Get by ID',
sample: { registrationId: 'reg-001' },
endpoint: '/api/registration/{registrationId}',
method: 'GET',
baseUrl: 'https://adpmgmt.usimdev.com'
},
{
id: 'Reject',
service: 'registration',
action: 'reject',
label: 'Registration: Reject Applicant',
sample: { registrationId: 'reg-001', reason: 'Incomplete information' },
endpoint: '/api/registration/{registrationId}/reject',
method: 'POST',
baseUrl: 'https://adpmgmt.usimdev.com'
},
{
id: 'Complete',
service: 'registration',
action: 'complete',
label: 'Registration: Approve (Complete)',
sample: { registrationId: 'reg-001', platformClientId: 'ADP-TestBusiness-0001' },
endpoint: '/api/registration/{registrationId}/complete',
method: 'POST',
baseUrl: 'https://adpmgmt.usimdev.com'
},
// Register — still hits Registration Function directly (CIAM JWT, not staff plane)
{
id: 'Register',
service: 'registration',
action: 'register',
label: 'Registration: New Prospect (test)',
sample: {
businessName: 'Test Business',
websiteUrl: 'https://test.com',
businessCategory: 'retail',
businessDescription: 'A test registration',
contactName: 'Test User',
contactEmail: 'test@example.com',
contactPhone: '(555) 000-1234'
},
endpoint: '/api/registration/register',
method: 'POST',
baseUrl: 'https://adpregapi.usimdev.com'
}
],
management: [
{
id: 'Ping',
service: 'system',
action: 'ping',
label: 'System: Health Check',
sample: {},
endpoint: '/health',
method: 'GET',
baseUrl: 'https://adpmgmt.usimdev.com'
},
{
id: 'HelpGet',
service: 'help',
action: 'get',
label: 'Help: Get Content by Key',
sample: { key: 'client.wizard.budget' },
endpoint: '/api/help/{key}',
method: 'GET',
baseUrl: 'https://adpmgmt.usimdev.com'
},
{
id: 'DocumentList',
service: 'documents',
action: 'list',
label: 'Documents: List All',
sample: {},
endpoint: '/api/documents',
method: 'GET',
baseUrl: 'https://adpmgmt.usimdev.com'
},
{
id: 'DocumentDelete',
service: 'documents',
action: 'delete',
label: 'Documents: Delete by ID',
sample: { docId: 1 },
endpoint: '/api/documents/{docId}',
method: 'DELETE',
baseUrl: 'https://adpmgmt.usimdev.com'
}
]
};
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);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
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']
}
};