Updated API Dashboard
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)' }}
|
||||
>
|
||||
×
|
||||
</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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user