Updated API Dashboard

This commit is contained in:
Sidharth Prabhu
2026-07-29 11:52:09 +05:30
parent ba06c1a7a6
commit 28a13d619c
19 changed files with 1386 additions and 136 deletions

View File

@@ -1238,3 +1238,148 @@ input:checked + .slider:before {
border-radius: 0.25rem;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.log-row-hover:hover {
background-color: rgba(0, 24, 40, 0.02);
}
/* Mobile responsive navigation & header */
.mobile-header {
display: none;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1.25rem;
background: rgba(255, 255, 255, 0.85);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 150;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.mobile-menu-toggle {
background: transparent;
border: none;
color: var(--color-text-dark);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
border-radius: 0.5rem;
transition: background 0.2s;
}
.mobile-menu-toggle:hover {
background: rgba(0, 0, 0, 0.05);
}
.mobile-brand-title {
font-size: 1rem;
font-weight: 800;
color: var(--color-text-dark);
letter-spacing: -0.01em;
}
.sidebar-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 190;
animation: fadeIn 0.2s ease-out forwards;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@media (max-width: 768px) {
.mobile-header {
display: flex;
}
.dashboard-sidebar {
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 10px 0 30px rgba(0,0,0,0.15);
z-index: 200;
}
.dashboard-sidebar.open {
transform: translateX(0);
}
.dashboard-main-content {
margin-left: 0;
padding: 1.25rem 1rem;
min-height: calc(100vh - 55px);
}
.panel-header h2 {
font-size: 1.25rem;
}
.panel-header p {
font-size: 0.85rem;
}
.premium-card {
padding: 1.25rem;
border-radius: 1rem;
}
/* Form & Key display adjustments */
.key-display-box {
flex-wrap: wrap !important;
gap: 0.5rem !important;
padding: 0.75rem !important;
}
.key-code {
font-size: 0.75rem !important;
word-break: break-all !important;
white-space: normal !important;
line-height: 1.4;
}
.limit-indicator {
align-self: stretch;
justify-content: center;
}
/* Stack columns vertically on small screens */
.keys-grid,
.sandbox-workspace {
grid-template-columns: 1fr !important;
gap: 1.25rem;
}
/* Stack logs split pane details below table on small screens */
.keys-grid + div,
div[style*="display: flex; gap: 1.5rem;"] {
flex-direction: column !important;
gap: 1rem !important;
}
/* Make sure split pane has full width on mobile */
div[style*="width: 400px;"] {
width: 100% !important;
}
/* Login optimizations */
.login-screen-wrapper {
padding: 1rem !important;
}
.login-card {
padding: 1.5rem 1.25rem !important;
gap: 1.25rem !important;
}
}

View File

@@ -16,6 +16,7 @@ interface DeveloperApiKey {
lastUsedAt?: string;
writeAccess?: boolean;
permissions?: string;
appId?: number;
}
interface UserSession {
@@ -52,8 +53,19 @@ function App() {
'READ_PRODUCTS', 'READ_STALLS', 'READ_ORDERS', 'READ_WALLETS'
]);
// Apps Management State
const [apps, setApps] = useState<any[]>([]);
const [newAppName, setNewAppName] = useState('');
const [newAppDesc, setNewAppDesc] = useState('');
// UI Tabs & Feedback
const [activeTab, setActiveTab] = useState<'keys' | 'playground' | 'docs'>('keys');
const [activeTab, setActiveTab] = useState<'keys' | 'playground' | 'docs' | 'logs'>('keys');
const [logs, setLogs] = useState<any[]>([]);
const [logPage, setLogPage] = useState(0);
const [logTotalPages, setLogTotalPages] = useState(0);
const [logTotalItems, setLogTotalItems] = useState(0);
const [selectedLog, setSelectedLog] = useState<any | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
@@ -84,12 +96,16 @@ function App() {
}
}, []);
// Fetch keys when session changes
// Fetch keys and apps when session changes
useEffect(() => {
if (session) {
fetchKeys();
if (session.roleOrType === 'CUSTOMER') {
fetchApps();
}
} else {
setKeys([]);
setApps([]);
}
}, [session]);
@@ -240,6 +256,142 @@ function App() {
}
};
const fetchApps = async () => {
if (!session) return;
try {
const res = await fetch(`${BACKEND_URL}/api/developer-apps`, {
headers: {
'Authorization': `Bearer ${session.token}`,
},
});
if (!res.ok) throw new Error('Failed to fetch apps');
const data = await res.json();
setApps(data);
} catch (err: any) {
showNotification('error', err.message);
}
};
const handleCreateApp = async (e: React.FormEvent) => {
e.preventDefault();
if (!session) return;
if (!newAppName.trim()) {
showNotification('error', 'Please enter a name for the app');
return;
}
setLoading(true);
clearMessages();
try {
const res = await fetch(`${BACKEND_URL}/api/developer-apps`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.token}`,
},
body: JSON.stringify({
name: newAppName,
description: newAppDesc
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to create app');
showNotification('success', 'App registered successfully');
setNewAppName('');
setNewAppDesc('');
fetchApps();
} catch (err: any) {
showNotification('error', err.message);
} finally {
setLoading(false);
}
};
const handleDeleteApp = async (appId: number) => {
if (!session) return;
if (!window.confirm('Are you sure you want to delete this App? This will also revoke its associated API key.')) return;
setLoading(true);
clearMessages();
try {
const res = await fetch(`${BACKEND_URL}/api/developer-apps/${appId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${session.token}`,
},
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to delete app');
showNotification('success', 'App deleted successfully');
fetchApps();
fetchKeys();
} catch (err: any) {
showNotification('error', err.message);
} finally {
setLoading(false);
}
};
const handleCreateKeyForApp = async (appId: number, name: string) => {
if (!session) return;
setLoading(true);
clearMessages();
try {
const permissionsPayload = 'READ_PRODUCTS,READ_STALLS,READ_ORDERS,READ_WALLETS';
const writeAccessPayload = false;
const res = await fetch(`${BACKEND_URL}/api/developer-keys`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.token}`,
},
body: JSON.stringify({
name: name,
writeAccess: writeAccessPayload,
permissions: permissionsPayload,
appId: appId
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to generate key');
showNotification('success', 'API Key generated successfully');
fetchKeys();
} catch (err: any) {
showNotification('error', err.message);
} finally {
setLoading(false);
}
};
const fetchLogs = async (page = 0) => {
if (!session) return;
setLoading(true);
try {
const res = await fetch(`${BACKEND_URL}/api/developer-keys/logs?page=${page}&size=10`, {
headers: {
'Authorization': `Bearer ${session.token}`,
},
});
if (!res.ok) throw new Error('Failed to fetch API logs');
const data = await res.json();
setLogs(data.content);
setLogPage(data.currentPage);
setLogTotalPages(data.totalPages);
setLogTotalItems(data.totalItems);
} catch (err: any) {
showNotification('error', err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (activeTab === 'logs' && session) {
fetchLogs(0);
}
}, [activeTab, session]);
const handleCreateKey = async (e: React.FormEvent) => {
e.preventDefault();
if (!session) return;
@@ -700,8 +852,25 @@ function App() {
</div>
) : (
<>
{/* Mobile Top Navigation Header */}
<div className="mobile-header">
<button className="mobile-menu-toggle" onClick={() => setSidebarOpen(!sidebarOpen)} title="Toggle menu">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
<div className="mobile-brand-title">Tillo Dev Portal</div>
</div>
{/* Sidebar Backdrop Overlay on Mobile */}
{sidebarOpen && (
<div className="sidebar-backdrop" onClick={() => setSidebarOpen(false)} />
)}
{/* Full Height left sidebar */}
<aside className="dashboard-sidebar glass-effect">
<aside className={`dashboard-sidebar glass-effect ${sidebarOpen ? 'open' : ''}`}>
<div className="sidebar-brand">
<img src={logo} alt="Branding" className="tillo-logo-img" />
</div>
@@ -709,7 +878,7 @@ function App() {
<nav className="sidebar-nav">
<button
className={`nav-item ${activeTab === 'keys' ? 'active' : ''}`}
onClick={() => setActiveTab('keys')}
onClick={() => { setActiveTab('keys'); setSidebarOpen(false); }}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="nav-icon">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
@@ -720,7 +889,7 @@ function App() {
<button
className={`nav-item ${activeTab === 'playground' ? 'active' : ''}`}
onClick={() => setActiveTab('playground')}
onClick={() => { setActiveTab('playground'); setSidebarOpen(false); }}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="nav-icon">
<polyline points="16 18 22 12 16 6"/>
@@ -729,10 +898,24 @@ function App() {
Developer Sandbox
</button>
<button
className={`nav-item ${activeTab === 'logs' ? 'active' : ''}`}
onClick={() => { setActiveTab('logs'); setSidebarOpen(false); }}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="nav-icon">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
API Request Logs
</button>
<div className="nav-item-group">
<button
className={`nav-item ${activeTab === 'docs' ? 'active' : ''}`}
onClick={() => setActiveTab('docs')}
onClick={() => { setActiveTab('docs'); setSidebarOpen(false); }}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="nav-icon">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
@@ -806,25 +989,25 @@ function App() {
</div>
{/* API Key list */}
<div className="keys-grid">
<div className="premium-card key-creator-card">
<h3>Generate New API Key</h3>
<p className="card-desc">Assign a friendly name to describe your application module usage.</p>
<form onSubmit={handleCreateKey} className="create-key-form">
<div className="input-group">
<label htmlFor="keyName">Application / Module Name</label>
<input
type="text"
id="keyName"
placeholder="e.g. Canteen Status Widget"
value={newKeyName}
disabled={isLimitReached}
onChange={(e) => setNewKeyName(e.target.value)}
/>
</div>
{isSystemUser ? (
<div className="keys-grid">
<div className="premium-card key-creator-card">
<h3>Generate New API Key</h3>
<p className="card-desc">Assign a friendly name to describe your application module usage.</p>
<form onSubmit={handleCreateKey} className="create-key-form">
<div className="input-group">
<label htmlFor="keyName">Application / Module Name</label>
<input
type="text"
id="keyName"
placeholder="e.g. Canteen Status Widget"
value={newKeyName}
disabled={isLimitReached}
onChange={(e) => setNewKeyName(e.target.value)}
/>
</div>
{isSystemUser && (
<div className="permissions-config animate-slideUp">
<label className="toggle-label-header">Elevated Key Privileges</label>
<div className="toggle-switch-wrapper">
@@ -906,115 +1089,489 @@ function App() {
})}
</div>
</div>
)}
<button
type="submit"
className="btn-primary w-full"
disabled={loading || isLimitReached}
style={{ marginTop: '1rem' }}
>
{isLimitReached ? 'Key Limit Reached (Max 3)' : 'Generate Key'}
</button>
</form>
<button
type="submit"
className="btn-primary w-full"
disabled={loading || isLimitReached}
style={{ marginTop: '1rem' }}
>
{isLimitReached ? 'Key Limit Reached (Max 3)' : 'Generate Key'}
</button>
</form>
{isSystemUser ? (
<div className="limit-indicator">
<span className="dot" style={{ backgroundColor: '#08a850' }}></span>
<span>Unlimited Staff API Keys Allowed</span>
</div>
) : (
</div>
<div className="keys-list-container">
{keys.length === 0 ? (
<div className="empty-state">
<div className="empty-icon">🔐</div>
<h3>No Active API Keys</h3>
<p>You haven't generated any API keys yet. Create one on the left to start accessing endpoints.</p>
</div>
) : (
keys.map((key) => (
<div key={key.id} className="premium-card api-key-card animate-slideUp">
<div className="key-card-header">
<div>
<h4>{key.name}</h4>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.25rem' }}>
<span className="badge-user-type">{key.userType} Key</span>
<span className={`badge-access-type ${key.writeAccess ? 'elevated' : 'readonly'}`}>
{key.writeAccess ? 'Elevated (Write)' : 'Read-Only'}
</span>
</div>
</div>
<button
onClick={() => handleRevokeKey(key.id)}
className="btn-revoke"
title="Revoke and Delete Key"
>
Revoke
</button>
</div>
<div className="key-display-box">
<code className="key-code">
{showSecrets[key.id] ? key.apiKey : `${key.apiKey.substring(0, 8)}••••••••••••••••••••••••••••`}
</code>
<div className="key-actions">
<button
onClick={() => toggleRevealKey(key.id)}
className="btn-icon-only"
title={showSecrets[key.id] ? 'Hide Key' : 'Reveal Key'}
>
{showSecrets[key.id] ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
)}
</button>
<button
onClick={() => copyToClipboard(key.apiKey)}
className="btn-icon-only"
title="Copy Key"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
</svg>
</button>
</div>
</div>
{key.permissions && (
<div className="key-permissions-list">
<span className="scopes-label">Scopes:</span>
<div className="scopes-badges">
{key.permissions.split(',').map(scope => (
<span key={scope} className="scope-tag">{scope}</span>
))}
</div>
</div>
)}
<div className="key-metadata">
<span>Created: <strong>{new Date(key.createdAt).toLocaleDateString()}</strong></span>
<span>Last Used: <strong>{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleTimeString() : 'Never'}</strong></span>
</div>
</div>
))
)}
</div>
</div>
) : (
<div className="keys-grid">
{/* CUSTOMER Mode: Register App and show App based Keys */}
<div className="premium-card key-creator-card">
<h3>Register New App</h3>
<p className="card-desc">First create an app to configure features and obtain its API credentials.</p>
<form onSubmit={handleCreateApp} className="create-key-form">
<div className="input-group">
<label htmlFor="appName">Application Name</label>
<input
type="text"
id="appName"
placeholder="e.g. My Canteen Companion"
value={newAppName}
onChange={(e) => setNewAppName(e.target.value)}
required
/>
</div>
<div className="input-group" style={{ marginTop: '1rem' }}>
<label htmlFor="appDesc">Description</label>
<textarea
id="appDesc"
placeholder="Describe what your app will build or access..."
value={newAppDesc}
onChange={(e) => setNewAppDesc(e.target.value)}
style={{
width: '100%',
padding: '0.85rem 1rem',
borderRadius: '0.85rem',
border: '1px solid var(--color-border)',
backgroundColor: 'var(--color-input-bg)',
color: 'var(--color-text-dark)',
fontFamily: 'inherit',
minHeight: '80px',
resize: 'vertical'
}}
/>
</div>
<button
type="submit"
className="btn-primary w-full"
disabled={loading}
style={{ marginTop: '1.5rem' }}
>
Register App
</button>
</form>
<div className="limit-indicator">
<span className="dot" style={{ backgroundColor: keys.length >= 3 ? '#ef4444' : '#08a850' }}></span>
<span>Keys generated: <strong>{keys.length} of 3 limit</strong></span>
<span>Total Keys generated: <strong>{keys.length} of 3 limit</strong></span>
</div>
)}
</div>
<div className="keys-list-container">
{apps.length === 0 ? (
<div className="empty-state">
<div className="empty-icon">📱</div>
<h3>No Registered Applications</h3>
<p>You need to register an App first before you can generate API keys.</p>
</div>
) : (
apps.map((app) => {
const appKey = keys.find(k => k.appId === app.id);
return (
<div key={app.id} className="premium-card api-key-card animate-slideUp" style={{ position: 'relative' }}>
<div className="key-card-header">
<div>
<h4 style={{ fontSize: '1.25rem', fontWeight: '800' }}>{app.name}</h4>
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.9rem', marginTop: '0.25rem' }}>
{app.description || 'No description provided.'}
</p>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
<span className="badge-user-type">App ID: {app.id}</span>
{appKey ? (
<span className="badge-access-type readonly">Active API Key</span>
) : (
<span className="badge-access-type" style={{ background: '#f59e0b', color: '#fff' }}>Missing API Key</span>
)}
</div>
</div>
<button
onClick={() => handleDeleteApp(app.id)}
className="btn-revoke"
style={{ background: 'rgba(239, 68, 68, 0.08)', color: 'var(--color-danger)' }}
title="Delete App and revoke key"
>
Delete App
</button>
</div>
{appKey ? (
<div style={{ marginTop: '1.5rem' }}>
<div className="key-display-box">
<code className="key-code">
{showSecrets[appKey.id] ? appKey.apiKey : `${appKey.apiKey.substring(0, 8)}••••••••••••••••••••••••••••`}
</code>
<div className="key-actions">
<button
onClick={() => toggleRevealKey(appKey.id)}
className="btn-icon-only"
title={showSecrets[appKey.id] ? 'Hide Key' : 'Reveal Key'}
>
{showSecrets[appKey.id] ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
)}
</button>
<button
onClick={() => copyToClipboard(appKey.apiKey)}
className="btn-icon-only"
title="Copy Key"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
</svg>
</button>
</div>
</div>
<div className="key-metadata" style={{ marginTop: '1rem', borderTop: '1px dashed var(--color-border)', paddingTop: '0.75rem' }}>
<span>Created: <strong>{new Date(appKey.createdAt).toLocaleDateString()}</strong></span>
<span>Last Used: <strong>{appKey.lastUsedAt ? new Date(appKey.lastUsedAt).toLocaleTimeString() : 'Never'}</strong></span>
</div>
</div>
) : (
<div style={{ marginTop: '1.5rem', textAlign: 'center', padding: '1.25rem', background: 'rgba(0,0,0,0.02)', borderRadius: '1rem', border: '1px dashed var(--color-border)' }}>
<p style={{ fontSize: '0.9rem', color: 'var(--color-text-secondary)', marginBottom: '1rem' }}>
No API key generated for this app yet.
</p>
<button
onClick={() => handleCreateKeyForApp(app.id, app.name)}
className="btn-primary"
disabled={loading || keys.length >= 3}
style={{ padding: '0.5rem 1.5rem', fontSize: '0.875rem' }}
>
{keys.length >= 3 ? 'Key Limit Reached (Max 3)' : 'Generate API Key'}
</button>
</div>
)}
</div>
);
})
)}
</div>
</div>
)}
</div>
)}
{activeTab === 'logs' && (
<div className="tab-pane animate-slideUp">
<div className="panel-header">
<h2>API Request Logs</h2>
<p>Track history, latency, and status codes of all requests querying your developer endpoints.</p>
</div>
<div className="premium-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h3 style={{ margin: 0 }}>Request History</h3>
<button onClick={() => fetchLogs(0)} className="btn-primary" style={{ padding: '0.4rem 1rem', fontSize: '0.85rem' }} disabled={loading}>
Refresh Logs
</button>
</div>
<div className="keys-list-container">
{keys.length === 0 ? (
<div className="empty-state">
<div className="empty-icon">🔐</div>
<h3>No Active API Keys</h3>
<p>You haven't generated any API keys yet. Create one on the left to start accessing endpoints.</p>
</div>
) : (
keys.map((key) => (
<div key={key.id} className="premium-card api-key-card animate-slideUp">
<div className="key-card-header">
<div>
<h4>{key.name}</h4>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.25rem' }}>
<span className="badge-user-type">{key.userType} Key</span>
<span className={`badge-access-type ${key.writeAccess ? 'elevated' : 'readonly'}`}>
{key.writeAccess ? 'Elevated (Write)' : 'Read-Only'}
</span>
</div>
</div>
<button
onClick={() => handleRevokeKey(key.id)}
className="btn-revoke"
title="Revoke and Delete Key"
>
Revoke
</button>
</div>
<div className="key-display-box">
<code className="key-code">
{showSecrets[key.id] ? key.apiKey : `${key.apiKey.substring(0, 8)}••••••••••••••••••••••••••••`}
</code>
<div className="key-actions">
<button
onClick={() => toggleRevealKey(key.id)}
className="btn-icon-only"
title={showSecrets[key.id] ? 'Hide Key' : 'Reveal Key'}
>
{showSecrets[key.id] ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
)}
</button>
<button
onClick={() => copyToClipboard(key.apiKey)}
className="btn-icon-only"
title="Copy Key"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
</svg>
</button>
</div>
</div>
{key.permissions && (
<div className="key-permissions-list">
<span className="scopes-label">Scopes:</span>
<div className="scopes-badges">
{key.permissions.split(',').map(scope => (
<span key={scope} className="scope-tag">{scope}</span>
))}
</div>
</div>
)}
<div className="key-metadata">
<span>Created: <strong>{new Date(key.createdAt).toLocaleDateString()}</strong></span>
<span>Last Used: <strong>{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleTimeString() : 'Never'}</strong></span>
{logs.length === 0 ? (
<div className="empty-state" style={{ padding: '3rem 0' }}>
<div className="empty-icon">📊</div>
<h3>No API Request History</h3>
<p>Use your API keys to call the API or test requests in the Developer Sandbox to populate logs.</p>
</div>
) : (
<>
<div style={{ display: 'flex', gap: '1.5rem', alignItems: 'flex-start' }}>
{/* Left: Logs Table */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left', minWidth: '550px' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--color-border)', paddingBottom: '0.75rem' }}>
<th style={{ padding: '0.75rem 1rem', fontSize: '0.8rem', fontWeight: '800', color: 'var(--color-text-secondary)', textTransform: 'uppercase' }}>Timestamp</th>
<th style={{ padding: '0.75rem 1rem', fontSize: '0.8rem', fontWeight: '800', color: 'var(--color-text-secondary)', textTransform: 'uppercase' }}>Method</th>
<th style={{ padding: '0.75rem 1rem', fontSize: '0.8rem', fontWeight: '800', color: 'var(--color-text-secondary)', textTransform: 'uppercase' }}>Endpoint</th>
<th style={{ padding: '0.75rem 1rem', fontSize: '0.8rem', fontWeight: '800', color: 'var(--color-text-secondary)', textTransform: 'uppercase' }}>Status</th>
<th style={{ padding: '0.75rem 1rem', fontSize: '0.8rem', fontWeight: '800', color: 'var(--color-text-secondary)', textTransform: 'uppercase' }}>IP Address</th>
</tr>
</thead>
<tbody>
{logs.map((log) => {
const isSuccess = log.status >= 200 && log.status < 300;
const isSelected = selectedLog && selectedLog.id === log.id;
return (
<tr
key={log.id}
onClick={() => setSelectedLog(log)}
style={{
borderBottom: '1px solid var(--color-border)',
transition: 'background 0.2s',
cursor: 'pointer',
backgroundColor: isSelected ? 'rgba(0, 24, 40, 0.06)' : 'transparent'
}}
className="log-row-hover"
>
<td style={{ padding: '1rem', fontSize: '0.875rem', color: 'var(--color-text-dark)', whiteSpace: 'nowrap' }}>
{new Date(log.timestamp).toLocaleString()}
</td>
<td style={{ padding: '1rem' }}>
<span style={{
padding: '0.25rem 0.6rem',
borderRadius: '0.5rem',
fontSize: '0.75rem',
fontWeight: '800',
color: '#fff',
backgroundColor: log.method === 'GET' ? '#3b82f6' : log.method === 'POST' ? '#10b981' : log.method === 'DELETE' ? '#ef4444' : '#6b7280'
}}>
{log.method}
</span>
</td>
<td style={{ padding: '1rem', fontSize: '0.875rem', fontFamily: 'monospace', color: 'var(--color-text-dark)', wordBreak: 'break-all' }}>
{log.endpoint}
</td>
<td style={{ padding: '1rem' }}>
<span style={{
padding: '0.25rem 0.6rem',
borderRadius: '0.5rem',
fontSize: '0.75rem',
fontWeight: '800',
color: isSuccess ? '#065f46' : '#991b1b',
backgroundColor: isSuccess ? '#d1fae5' : '#fee2e2'
}}>
{log.status}
</span>
</td>
<td style={{ padding: '1rem', fontSize: '0.875rem', color: 'var(--color-text-secondary)' }}>
{log.clientIp || 'Unknown'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
))
)}
</div>
{/* Right: Response JSON Detail View */}
{selectedLog && (
<div className="premium-card animate-slideUp" style={{ width: '400px', display: 'flex', flexDirection: 'column', flexShrink: 0, padding: '1.5rem', background: 'rgba(0,0,0,0.01)', border: '1px solid var(--color-border)', borderRadius: '1rem', maxHeight: '550px', overflowY: 'auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', borderBottom: '1px solid var(--color-border)', paddingBottom: '0.75rem' }}>
<h4 style={{ margin: 0, fontSize: '1.05rem', fontWeight: '800', color: 'var(--color-text-dark)' }}>Response Payload</h4>
<button
onClick={() => setSelectedLog(null)}
style={{ background: 'transparent', border: 'none', fontSize: '1.25rem', cursor: 'pointer', color: 'var(--color-text-secondary)' }}
>
&times;
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem', fontSize: '0.825rem', marginBottom: '1rem' }}>
<div>
<strong style={{ color: 'var(--color-text-secondary)' }}>Endpoint: </strong>
<span style={{ fontFamily: 'monospace' }}>{selectedLog.endpoint}</span>
</div>
<div style={{ display: 'flex', gap: '1rem' }}>
<div>
<strong style={{ color: 'var(--color-text-secondary)' }}>Method: </strong>
<span style={{
padding: '0.15rem 0.4rem',
borderRadius: '0.25rem',
fontSize: '0.7rem',
fontWeight: '800',
color: '#fff',
backgroundColor: selectedLog.method === 'GET' ? '#3b82f6' : selectedLog.method === 'POST' ? '#10b981' : selectedLog.method === 'DELETE' ? '#ef4444' : '#6b7280'
}}>
{selectedLog.method}
</span>
</div>
<div>
<strong style={{ color: 'var(--color-text-secondary)' }}>Status: </strong>
<span style={{
padding: '0.15rem 0.4rem',
borderRadius: '0.25rem',
fontSize: '0.7rem',
fontWeight: '800',
color: (selectedLog.status >= 200 && selectedLog.status < 300) ? '#065f46' : '#991b1b',
backgroundColor: (selectedLog.status >= 200 && selectedLog.status < 300) ? '#d1fae5' : '#fee2e2'
}}>
{selectedLog.status}
</span>
</div>
</div>
<div>
<strong style={{ color: 'var(--color-text-secondary)' }}>IP Address: </strong>
<span>{selectedLog.clientIp || 'Unknown'}</span>
</div>
<div>
<strong style={{ color: 'var(--color-text-secondary)' }}>Timestamp: </strong>
<span>{new Date(selectedLog.timestamp).toLocaleString()}</span>
</div>
</div>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<strong style={{ color: 'var(--color-text-secondary)', fontSize: '0.825rem', display: 'block', marginBottom: '0.5rem' }}>Response JSON:</strong>
<pre style={{
margin: 0,
padding: '0.75rem',
backgroundColor: 'rgba(0,0,0,0.03)',
border: '1px solid var(--color-border)',
borderRadius: '0.5rem',
fontFamily: 'monospace',
fontSize: '0.75rem',
color: 'var(--color-text-dark)',
overflowX: 'auto',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
maxHeight: '300px'
}}>
{selectedLog.responseBody ? (
(() => {
try {
return JSON.stringify(JSON.parse(selectedLog.responseBody), null, 2);
} catch (e) {
return selectedLog.responseBody;
}
})()
) : (
"No response body returned or logged."
)}
</pre>
</div>
</div>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
<span style={{ fontSize: '0.875rem', color: 'var(--color-text-secondary)' }}>
Showing Page <strong>{logPage + 1}</strong> of <strong>{logTotalPages || 1}</strong> (<strong>{logTotalItems}</strong> items)
</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={() => fetchLogs(logPage - 1)}
disabled={logPage === 0 || loading}
className="btn-primary"
style={{
padding: '0.4rem 1rem',
fontSize: '0.85rem',
opacity: logPage === 0 ? 0.5 : 1,
cursor: logPage === 0 ? 'not-allowed' : 'pointer'
}}
>
Previous
</button>
<button
onClick={() => fetchLogs(logPage + 1)}
disabled={logPage + 1 >= logTotalPages || loading}
className="btn-primary"
style={{
padding: '0.4rem 1rem',
fontSize: '0.85rem',
opacity: logPage + 1 >= logTotalPages ? 0.5 : 1,
cursor: logPage + 1 >= logTotalPages ? 'not-allowed' : 'pointer'
}}
>
Next
</button>
</div>
</div>
</>
)}
</div>
</div>
)}