Updated API Dashboard
This commit is contained in:
@@ -1238,3 +1238,148 @@ input:checked + .slider:before {
|
|||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
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;
|
lastUsedAt?: string;
|
||||||
writeAccess?: boolean;
|
writeAccess?: boolean;
|
||||||
permissions?: string;
|
permissions?: string;
|
||||||
|
appId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserSession {
|
interface UserSession {
|
||||||
@@ -52,8 +53,19 @@ function App() {
|
|||||||
'READ_PRODUCTS', 'READ_STALLS', 'READ_ORDERS', 'READ_WALLETS'
|
'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
|
// 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 [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = 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(() => {
|
useEffect(() => {
|
||||||
if (session) {
|
if (session) {
|
||||||
fetchKeys();
|
fetchKeys();
|
||||||
|
if (session.roleOrType === 'CUSTOMER') {
|
||||||
|
fetchApps();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setKeys([]);
|
setKeys([]);
|
||||||
|
setApps([]);
|
||||||
}
|
}
|
||||||
}, [session]);
|
}, [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) => {
|
const handleCreateKey = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
@@ -700,8 +852,25 @@ function App() {
|
|||||||
</div>
|
</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 */}
|
{/* Full Height left sidebar */}
|
||||||
<aside className="dashboard-sidebar glass-effect">
|
<aside className={`dashboard-sidebar glass-effect ${sidebarOpen ? 'open' : ''}`}>
|
||||||
<div className="sidebar-brand">
|
<div className="sidebar-brand">
|
||||||
<img src={logo} alt="Branding" className="tillo-logo-img" />
|
<img src={logo} alt="Branding" className="tillo-logo-img" />
|
||||||
</div>
|
</div>
|
||||||
@@ -709,7 +878,7 @@ function App() {
|
|||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
<button
|
<button
|
||||||
className={`nav-item ${activeTab === 'keys' ? 'active' : ''}`}
|
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">
|
<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"/>
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||||
@@ -720,7 +889,7 @@ function App() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className={`nav-item ${activeTab === 'playground' ? 'active' : ''}`}
|
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">
|
<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"/>
|
<polyline points="16 18 22 12 16 6"/>
|
||||||
@@ -729,10 +898,24 @@ function App() {
|
|||||||
Developer Sandbox
|
Developer Sandbox
|
||||||
</button>
|
</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">
|
<div className="nav-item-group">
|
||||||
<button
|
<button
|
||||||
className={`nav-item ${activeTab === 'docs' ? 'active' : ''}`}
|
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">
|
<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"/>
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
|
||||||
@@ -806,25 +989,25 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Key list */}
|
{/* API Key list */}
|
||||||
<div className="keys-grid">
|
{isSystemUser ? (
|
||||||
<div className="premium-card key-creator-card">
|
<div className="keys-grid">
|
||||||
<h3>Generate New API Key</h3>
|
<div className="premium-card key-creator-card">
|
||||||
<p className="card-desc">Assign a friendly name to describe your application module usage.</p>
|
<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">
|
<form onSubmit={handleCreateKey} className="create-key-form">
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label htmlFor="keyName">Application / Module Name</label>
|
<label htmlFor="keyName">Application / Module Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="keyName"
|
id="keyName"
|
||||||
placeholder="e.g. Canteen Status Widget"
|
placeholder="e.g. Canteen Status Widget"
|
||||||
value={newKeyName}
|
value={newKeyName}
|
||||||
disabled={isLimitReached}
|
disabled={isLimitReached}
|
||||||
onChange={(e) => setNewKeyName(e.target.value)}
|
onChange={(e) => setNewKeyName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSystemUser && (
|
|
||||||
<div className="permissions-config animate-slideUp">
|
<div className="permissions-config animate-slideUp">
|
||||||
<label className="toggle-label-header">Elevated Key Privileges</label>
|
<label className="toggle-label-header">Elevated Key Privileges</label>
|
||||||
<div className="toggle-switch-wrapper">
|
<div className="toggle-switch-wrapper">
|
||||||
@@ -906,115 +1089,489 @@ function App() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn-primary w-full"
|
className="btn-primary w-full"
|
||||||
disabled={loading || isLimitReached}
|
disabled={loading || isLimitReached}
|
||||||
style={{ marginTop: '1rem' }}
|
style={{ marginTop: '1rem' }}
|
||||||
>
|
>
|
||||||
{isLimitReached ? 'Key Limit Reached (Max 3)' : 'Generate Key'}
|
{isLimitReached ? 'Key Limit Reached (Max 3)' : 'Generate Key'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{isSystemUser ? (
|
|
||||||
<div className="limit-indicator">
|
<div className="limit-indicator">
|
||||||
<span className="dot" style={{ backgroundColor: '#08a850' }}></span>
|
<span className="dot" style={{ backgroundColor: '#08a850' }}></span>
|
||||||
<span>Unlimited Staff API Keys Allowed</span>
|
<span>Unlimited Staff API Keys Allowed</span>
|
||||||
</div>
|
</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">
|
<div className="limit-indicator">
|
||||||
<span className="dot" style={{ backgroundColor: keys.length >= 3 ? '#ef4444' : '#08a850' }}></span>
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="keys-list-container">
|
{logs.length === 0 ? (
|
||||||
{keys.length === 0 ? (
|
<div className="empty-state" style={{ padding: '3rem 0' }}>
|
||||||
<div className="empty-state">
|
<div className="empty-icon">📊</div>
|
||||||
<div className="empty-icon">🔐</div>
|
<h3>No API Request History</h3>
|
||||||
<h3>No Active API Keys</h3>
|
<p>Use your API keys to call the API or test requests in the Developer Sandbox to populate logs.</p>
|
||||||
<p>You haven't generated any API keys yet. Create one on the left to start accessing endpoints.</p>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<>
|
||||||
keys.map((key) => (
|
<div style={{ display: 'flex', gap: '1.5rem', alignItems: 'flex-start' }}>
|
||||||
<div key={key.id} className="premium-card api-key-card animate-slideUp">
|
{/* Left: Logs Table */}
|
||||||
<div className="key-card-header">
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<h4>{key.name}</h4>
|
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left', minWidth: '550px' }}>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.25rem' }}>
|
<thead>
|
||||||
<span className="badge-user-type">{key.userType} Key</span>
|
<tr style={{ borderBottom: '2px solid var(--color-border)', paddingBottom: '0.75rem' }}>
|
||||||
<span className={`badge-access-type ${key.writeAccess ? 'elevated' : 'readonly'}`}>
|
<th style={{ padding: '0.75rem 1rem', fontSize: '0.8rem', fontWeight: '800', color: 'var(--color-text-secondary)', textTransform: 'uppercase' }}>Timestamp</th>
|
||||||
{key.writeAccess ? 'Elevated (Write)' : 'Read-Only'}
|
<th style={{ padding: '0.75rem 1rem', fontSize: '0.8rem', fontWeight: '800', color: 'var(--color-text-secondary)', textTransform: 'uppercase' }}>Method</th>
|
||||||
</span>
|
<th style={{ padding: '0.75rem 1rem', fontSize: '0.8rem', fontWeight: '800', color: 'var(--color-text-secondary)', textTransform: 'uppercase' }}>Endpoint</th>
|
||||||
</div>
|
<th style={{ padding: '0.75rem 1rem', fontSize: '0.8rem', fontWeight: '800', color: 'var(--color-text-secondary)', textTransform: 'uppercase' }}>Status</th>
|
||||||
</div>
|
<th style={{ padding: '0.75rem 1rem', fontSize: '0.8rem', fontWeight: '800', color: 'var(--color-text-secondary)', textTransform: 'uppercase' }}>IP Address</th>
|
||||||
<button
|
</tr>
|
||||||
onClick={() => handleRevokeKey(key.id)}
|
</thead>
|
||||||
className="btn-revoke"
|
<tbody>
|
||||||
title="Revoke and Delete Key"
|
{logs.map((log) => {
|
||||||
>
|
const isSuccess = log.status >= 200 && log.status < 300;
|
||||||
Revoke
|
const isSelected = selectedLog && selectedLog.id === log.id;
|
||||||
</button>
|
return (
|
||||||
</div>
|
<tr
|
||||||
|
key={log.id}
|
||||||
<div className="key-display-box">
|
onClick={() => setSelectedLog(log)}
|
||||||
<code className="key-code">
|
style={{
|
||||||
{showSecrets[key.id] ? key.apiKey : `${key.apiKey.substring(0, 8)}••••••••••••••••••••••••••••`}
|
borderBottom: '1px solid var(--color-border)',
|
||||||
</code>
|
transition: 'background 0.2s',
|
||||||
<div className="key-actions">
|
cursor: 'pointer',
|
||||||
<button
|
backgroundColor: isSelected ? 'rgba(0, 24, 40, 0.06)' : 'transparent'
|
||||||
onClick={() => toggleRevealKey(key.id)}
|
}}
|
||||||
className="btn-icon-only"
|
className="log-row-hover"
|
||||||
title={showSecrets[key.id] ? 'Hide Key' : 'Reveal Key'}
|
>
|
||||||
>
|
<td style={{ padding: '1rem', fontSize: '0.875rem', color: 'var(--color-text-dark)', whiteSpace: 'nowrap' }}>
|
||||||
{showSecrets[key.id] ? (
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
</td>
|
||||||
<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"/>
|
<td style={{ padding: '1rem' }}>
|
||||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
<span style={{
|
||||||
</svg>
|
padding: '0.25rem 0.6rem',
|
||||||
) : (
|
borderRadius: '0.5rem',
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
fontSize: '0.75rem',
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
fontWeight: '800',
|
||||||
<circle cx="12" cy="12" r="3"/>
|
color: '#fff',
|
||||||
</svg>
|
backgroundColor: log.method === 'GET' ? '#3b82f6' : log.method === 'POST' ? '#10b981' : log.method === 'DELETE' ? '#ef4444' : '#6b7280'
|
||||||
)}
|
}}>
|
||||||
</button>
|
{log.method}
|
||||||
|
</span>
|
||||||
<button
|
</td>
|
||||||
onClick={() => copyToClipboard(key.apiKey)}
|
<td style={{ padding: '1rem', fontSize: '0.875rem', fontFamily: 'monospace', color: 'var(--color-text-dark)', wordBreak: 'break-all' }}>
|
||||||
className="btn-icon-only"
|
{log.endpoint}
|
||||||
title="Copy Key"
|
</td>
|
||||||
>
|
<td style={{ padding: '1rem' }}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<span style={{
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
padding: '0.25rem 0.6rem',
|
||||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
|
borderRadius: '0.5rem',
|
||||||
</svg>
|
fontSize: '0.75rem',
|
||||||
</button>
|
fontWeight: '800',
|
||||||
</div>
|
color: isSuccess ? '#065f46' : '#991b1b',
|
||||||
</div>
|
backgroundColor: isSuccess ? '#d1fae5' : '#fee2e2'
|
||||||
|
}}>
|
||||||
{key.permissions && (
|
{log.status}
|
||||||
<div className="key-permissions-list">
|
</span>
|
||||||
<span className="scopes-label">Scopes:</span>
|
</td>
|
||||||
<div className="scopes-badges">
|
<td style={{ padding: '1rem', fontSize: '0.875rem', color: 'var(--color-text-secondary)' }}>
|
||||||
{key.permissions.split(',').map(scope => (
|
{log.clientIp || 'Unknown'}
|
||||||
<span key={scope} className="scope-tag">{scope}</span>
|
</td>
|
||||||
))}
|
</tr>
|
||||||
</div>
|
);
|
||||||
</div>
|
})}
|
||||||
)}
|
</tbody>
|
||||||
|
</table>
|
||||||
<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>
|
||||||
))
|
|
||||||
)}
|
{/* Right: Response JSON Detail View */}
|
||||||
</div>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.rit.canteen.sales.config;
|
||||||
|
|
||||||
|
import com.rit.canteen.sales.model.DeveloperApiKey;
|
||||||
|
import com.rit.canteen.sales.model.DeveloperApiLog;
|
||||||
|
import com.rit.canteen.sales.repository.DeveloperApiKeyRepository;
|
||||||
|
import com.rit.canteen.sales.repository.DeveloperApiLogRepository;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class DeveloperApiLogInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DeveloperApiKeyRepository keyRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DeveloperApiLogRepository logRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
|
||||||
|
String uri = request.getRequestURI();
|
||||||
|
if (uri.startsWith("/api/developer/v1/")) {
|
||||||
|
String apiKeyHeader = request.getHeader("X-Developer-Key");
|
||||||
|
String method = request.getMethod();
|
||||||
|
int status = response.getStatus();
|
||||||
|
String clientIp = request.getHeader("X-Forwarded-For");
|
||||||
|
if (clientIp == null || clientIp.isEmpty()) {
|
||||||
|
clientIp = request.getRemoteAddr();
|
||||||
|
}
|
||||||
|
|
||||||
|
DeveloperApiLog log = new DeveloperApiLog();
|
||||||
|
log.setEndpoint(uri);
|
||||||
|
log.setMethod(method);
|
||||||
|
log.setStatus(status);
|
||||||
|
log.setClientIp(clientIp);
|
||||||
|
|
||||||
|
String responseBody = (String) request.getAttribute("developer_api_response_body");
|
||||||
|
if (responseBody != null) {
|
||||||
|
log.setResponseBody(responseBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiKeyHeader != null && !apiKeyHeader.trim().isEmpty()) {
|
||||||
|
log.setApiKey(apiKeyHeader);
|
||||||
|
Optional<DeveloperApiKey> keyOpt = keyRepository.findByApiKey(apiKeyHeader);
|
||||||
|
if (keyOpt.isPresent()) {
|
||||||
|
DeveloperApiKey key = keyOpt.get();
|
||||||
|
log.setAppId(key.getAppId());
|
||||||
|
log.setUserId(key.getUserId());
|
||||||
|
log.setUserType(key.getUserType());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logRepository.save(log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.rit.canteen.sales.config;
|
||||||
|
|
||||||
|
import com.rit.canteen.sales.controller.DeveloperApiController;
|
||||||
|
import org.springframework.core.MethodParameter;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.converter.HttpMessageConverter;
|
||||||
|
import org.springframework.http.server.ServerHttpRequest;
|
||||||
|
import org.springframework.http.server.ServerHttpResponse;
|
||||||
|
import org.springframework.http.server.ServletServerHttpRequest;
|
||||||
|
import org.springframework.http.server.ServletServerHttpResponse;
|
||||||
|
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
@ControllerAdvice(assignableTypes = {DeveloperApiController.class})
|
||||||
|
public class DeveloperApiResponseAdvice implements ResponseBodyAdvice<Object> {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
|
||||||
|
Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
||||||
|
ServerHttpRequest request, ServerHttpResponse response) {
|
||||||
|
try {
|
||||||
|
if (request instanceof ServletServerHttpRequest servletRequest) {
|
||||||
|
HttpServletRequest httpReq = servletRequest.getServletRequest();
|
||||||
|
if (body != null) {
|
||||||
|
String json = objectMapper.writeValueAsString(body);
|
||||||
|
httpReq.setAttribute("developer_api_response_body", json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,6 +92,7 @@ public class SecurityConfig {
|
|||||||
.requestMatchers(HttpMethod.PUT, "/api/orders/*").authenticated()
|
.requestMatchers(HttpMethod.PUT, "/api/orders/*").authenticated()
|
||||||
.requestMatchers(HttpMethod.POST, "/api/orders/*/cancel").authenticated()
|
.requestMatchers(HttpMethod.POST, "/api/orders/*/cancel").authenticated()
|
||||||
.requestMatchers("/api/developer-keys/**").authenticated()
|
.requestMatchers("/api/developer-keys/**").authenticated()
|
||||||
|
.requestMatchers("/api/developer-apps/**").authenticated()
|
||||||
.requestMatchers("/api/developer/v1/**").permitAll()
|
.requestMatchers("/api/developer/v1/**").permitAll()
|
||||||
.requestMatchers("/api/counter/**").hasAnyRole("MASTER", "MANAGER", "STAFF")
|
.requestMatchers("/api/counter/**").hasAnyRole("MASTER", "MANAGER", "STAFF")
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
package com.rit.canteen.sales.config;
|
package com.rit.canteen.sales.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
/**
|
|
||||||
* WebConfig intentionally left minimal.
|
|
||||||
* CORS is now fully managed by SecurityConfig.corsConfigurationSource()
|
|
||||||
* to avoid duplicate/conflicting CORS headers.
|
|
||||||
*/
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class WebConfig {
|
public class WebConfig implements WebMvcConfigurer {
|
||||||
// CORS handled by SecurityConfig — do not add CorsRegistry here
|
|
||||||
|
@Autowired
|
||||||
|
private DeveloperApiLogInterceptor logInterceptor;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
registry.addInterceptor(logInterceptor).addPathPatterns("/api/developer/v1/**");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import org.springframework.security.core.Authentication;
|
|||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import com.rit.canteen.sales.model.DeveloperApiLog;
|
||||||
|
import com.rit.canteen.sales.repository.DeveloperApiLogRepository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -19,6 +22,30 @@ public class DeveloperApiKeyController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private DeveloperApiKeyService keyService;
|
private DeveloperApiKeyService keyService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DeveloperApiLogRepository logRepository;
|
||||||
|
|
||||||
|
@GetMapping("/logs")
|
||||||
|
public ResponseEntity<?> listLogs(
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "10") int size) {
|
||||||
|
UserContext context = getUserContext();
|
||||||
|
if (context == null) {
|
||||||
|
return ResponseEntity.status(401).body(Map.of("error", "Unauthorized"));
|
||||||
|
}
|
||||||
|
org.springframework.data.domain.Pageable pageable = org.springframework.data.domain.PageRequest.of(
|
||||||
|
page, size, org.springframework.data.domain.Sort.by("timestamp").descending());
|
||||||
|
org.springframework.data.domain.Page<DeveloperApiLog> logPage = logRepository.findByUserIdAndUserType(
|
||||||
|
context.userId, context.userType, pageable);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"content", logPage.getContent(),
|
||||||
|
"currentPage", logPage.getNumber(),
|
||||||
|
"totalItems", logPage.getTotalElements(),
|
||||||
|
"totalPages", logPage.getTotalPages(),
|
||||||
|
"pageSize", logPage.getSize()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<?> listKeys() {
|
public ResponseEntity<?> listKeys() {
|
||||||
UserContext context = getUserContext();
|
UserContext context = getUserContext();
|
||||||
@@ -56,8 +83,17 @@ public class DeveloperApiKeyController {
|
|||||||
permissions = "READ_PRODUCTS,READ_STALLS,READ_ORDERS,READ_WALLETS";
|
permissions = "READ_PRODUCTS,READ_STALLS,READ_ORDERS,READ_WALLETS";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Long appId = null;
|
||||||
|
if (body.containsKey("appId") && body.get("appId") != null) {
|
||||||
|
try {
|
||||||
|
appId = Long.valueOf(body.get("appId").toString());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
DeveloperApiKey newKey = keyService.createKey(context.userId, context.userType, context.identifier, name, writeAccess, permissions);
|
DeveloperApiKey newKey = keyService.createKey(context.userId, context.userType, context.identifier, name, writeAccess, permissions, appId);
|
||||||
return ResponseEntity.status(201).body(newKey);
|
return ResponseEntity.status(201).body(newKey);
|
||||||
} catch (IllegalStateException e) {
|
} catch (IllegalStateException e) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package com.rit.canteen.sales.controller;
|
||||||
|
|
||||||
|
import com.rit.canteen.sales.model.DeveloperApp;
|
||||||
|
import com.rit.canteen.sales.repository.DeveloperAppRepository;
|
||||||
|
import com.rit.canteen.sales.repository.DeveloperApiKeyRepository;
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/developer-apps")
|
||||||
|
public class DeveloperAppController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DeveloperAppRepository appRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DeveloperApiKeyRepository keyRepository;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<?> listApps() {
|
||||||
|
UserContext context = getUserContext();
|
||||||
|
if (context == null) {
|
||||||
|
return ResponseEntity.status(401).body(Map.of("error", "Unauthorized"));
|
||||||
|
}
|
||||||
|
List<DeveloperApp> apps = appRepository.findByUserIdAndUserType(context.userId, context.userType);
|
||||||
|
return ResponseEntity.ok(apps);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<?> createApp(@RequestBody Map<String, Object> body) {
|
||||||
|
UserContext context = getUserContext();
|
||||||
|
if (context == null) {
|
||||||
|
return ResponseEntity.status(401).body(Map.of("error", "Unauthorized"));
|
||||||
|
}
|
||||||
|
String name = body.containsKey("name") && body.get("name") != null
|
||||||
|
? body.get("name").toString()
|
||||||
|
: "";
|
||||||
|
String description = body.containsKey("description") && body.get("description") != null
|
||||||
|
? body.get("description").toString()
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (name.trim().isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "App name is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
DeveloperApp app = new DeveloperApp();
|
||||||
|
app.setName(name);
|
||||||
|
app.setDescription(description);
|
||||||
|
app.setUserId(context.userId);
|
||||||
|
app.setUserType(context.userType);
|
||||||
|
app.setOwnerIdentifier(context.identifier);
|
||||||
|
|
||||||
|
DeveloperApp saved = appRepository.save(app);
|
||||||
|
return ResponseEntity.status(201).body(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<?> deleteApp(@PathVariable Long id) {
|
||||||
|
UserContext context = getUserContext();
|
||||||
|
if (context == null) {
|
||||||
|
return ResponseEntity.status(401).body(Map.of("error", "Unauthorized"));
|
||||||
|
}
|
||||||
|
Optional<DeveloperApp> appOpt = appRepository.findById(id);
|
||||||
|
if (appOpt.isEmpty()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
DeveloperApp app = appOpt.get();
|
||||||
|
if (!app.getUserId().equals(context.userId) || !app.getUserType().equals(context.userType)) {
|
||||||
|
return ResponseEntity.status(403).body(Map.of("error", "Unauthorized to delete this App"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete keys associated with this App first
|
||||||
|
keyRepository.deleteByAppId(id);
|
||||||
|
appRepository.delete(app);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of("success", true, "message", "App and its keys deleted successfully"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper UserContext parser ──────────────────────────────────────────
|
||||||
|
|
||||||
|
private UserContext getUserContext() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth == null || !auth.isAuthenticated()) return null;
|
||||||
|
|
||||||
|
if (auth.getDetails() instanceof Claims claims) {
|
||||||
|
Long userId;
|
||||||
|
Object uid = claims.get("userId");
|
||||||
|
if (uid instanceof Integer) {
|
||||||
|
userId = ((Integer) uid).longValue();
|
||||||
|
} else if (uid instanceof Long) {
|
||||||
|
userId = (Long) uid;
|
||||||
|
} else if (uid != null) {
|
||||||
|
userId = Long.valueOf(uid.toString());
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String type = (String) claims.get("type");
|
||||||
|
String userType = "customer".equals(type) ? "CUSTOMER" : "SYSTEM";
|
||||||
|
String identifier = claims.getSubject();
|
||||||
|
|
||||||
|
return new UserContext(userId, userType, identifier);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class UserContext {
|
||||||
|
final Long userId;
|
||||||
|
final String userType;
|
||||||
|
final String identifier;
|
||||||
|
|
||||||
|
UserContext(Long userId, String userType, String identifier) {
|
||||||
|
this.userId = userId;
|
||||||
|
this.userType = userType;
|
||||||
|
this.identifier = identifier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,9 @@ public class DeveloperApiKey {
|
|||||||
|
|
||||||
private LocalDateTime lastUsedAt;
|
private LocalDateTime lastUsedAt;
|
||||||
|
|
||||||
|
@Column(nullable = true)
|
||||||
|
private Long appId;
|
||||||
|
|
||||||
public DeveloperApiKey() {}
|
public DeveloperApiKey() {}
|
||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
@@ -134,4 +137,12 @@ public class DeveloperApiKey {
|
|||||||
public void setPermissions(String permissions) {
|
public void setPermissions(String permissions) {
|
||||||
this.permissions = permissions;
|
this.permissions = permissions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Long getAppId() {
|
||||||
|
return appId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAppId(Long appId) {
|
||||||
|
this.appId = appId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package com.rit.canteen.sales.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "developer_api_logs")
|
||||||
|
public class DeveloperApiLog {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String endpoint;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String method;
|
||||||
|
|
||||||
|
@Column(nullable = true)
|
||||||
|
private String apiKey;
|
||||||
|
|
||||||
|
@Column(nullable = true)
|
||||||
|
private Long appId;
|
||||||
|
|
||||||
|
@Column(nullable = true)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Column(nullable = true)
|
||||||
|
private String userType;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private int status;
|
||||||
|
|
||||||
|
@Column(nullable = true)
|
||||||
|
private String clientIp;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime timestamp;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String responseBody;
|
||||||
|
|
||||||
|
public DeveloperApiLog() {}
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
timestamp = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEndpoint() {
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEndpoint(String endpoint) {
|
||||||
|
this.endpoint = endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMethod() {
|
||||||
|
return method;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMethod(String method) {
|
||||||
|
this.method = method;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getApiKey() {
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setApiKey(String apiKey) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getAppId() {
|
||||||
|
return appId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAppId(Long appId) {
|
||||||
|
this.appId = appId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getUserId() {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserId(Long userId) {
|
||||||
|
this.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserType() {
|
||||||
|
return userType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserType(String userType) {
|
||||||
|
this.userType = userType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(int status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getClientIp() {
|
||||||
|
return clientIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setClientIp(String clientIp) {
|
||||||
|
this.clientIp = clientIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getTimestamp() {
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimestamp(LocalDateTime timestamp) {
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getResponseBody() {
|
||||||
|
return responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setResponseBody(String responseBody) {
|
||||||
|
this.responseBody = responseBody;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.rit.canteen.sales.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "developer_apps")
|
||||||
|
public class DeveloperApp {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String userType; // "CUSTOMER"
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String ownerIdentifier; // Email or Mobile Number
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
public DeveloperApp() {}
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescription(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getUserId() {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserId(Long userId) {
|
||||||
|
this.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserType() {
|
||||||
|
return userType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserType(String userType) {
|
||||||
|
this.userType = userType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOwnerIdentifier() {
|
||||||
|
return ownerIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOwnerIdentifier(String ownerIdentifier) {
|
||||||
|
this.ownerIdentifier = ownerIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,4 +9,6 @@ public interface DeveloperApiKeyRepository extends JpaRepository<DeveloperApiKey
|
|||||||
Optional<DeveloperApiKey> findByApiKey(String apiKey);
|
Optional<DeveloperApiKey> findByApiKey(String apiKey);
|
||||||
List<DeveloperApiKey> findByUserIdAndUserType(Long userId, String userType);
|
List<DeveloperApiKey> findByUserIdAndUserType(Long userId, String userType);
|
||||||
long countByUserIdAndUserType(Long userId, String userType);
|
long countByUserIdAndUserType(Long userId, String userType);
|
||||||
|
List<DeveloperApiKey> findByAppId(Long appId);
|
||||||
|
void deleteByAppId(Long appId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.rit.canteen.sales.repository;
|
||||||
|
|
||||||
|
import com.rit.canteen.sales.model.DeveloperApiLog;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface DeveloperApiLogRepository extends JpaRepository<DeveloperApiLog, Long> {
|
||||||
|
Page<DeveloperApiLog> findByUserIdAndUserType(Long userId, String userType, Pageable pageable);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.rit.canteen.sales.repository;
|
||||||
|
|
||||||
|
import com.rit.canteen.sales.model.DeveloperApp;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface DeveloperAppRepository extends JpaRepository<DeveloperApp, Long> {
|
||||||
|
List<DeveloperApp> findByUserIdAndUserType(Long userId, String userType);
|
||||||
|
}
|
||||||
@@ -24,12 +24,20 @@ public class DeveloperApiKeyService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public DeveloperApiKey createKey(Long userId, String userType, String ownerIdentifier, String name, boolean writeAccess, String permissions) {
|
public DeveloperApiKey createKey(Long userId, String userType, String ownerIdentifier, String name, boolean writeAccess, String permissions) {
|
||||||
|
return createKey(userId, userType, ownerIdentifier, name, writeAccess, permissions, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public DeveloperApiKey createKey(Long userId, String userType, String ownerIdentifier, String name, boolean writeAccess, String permissions, Long appId) {
|
||||||
// Enforce the 3 key limit only for regular CUSTOMER users. SYSTEM users (admin/managers) get unlimited keys.
|
// Enforce the 3 key limit only for regular CUSTOMER users. SYSTEM users (admin/managers) get unlimited keys.
|
||||||
if (!"SYSTEM".equalsIgnoreCase(userType)) {
|
if (!"SYSTEM".equalsIgnoreCase(userType)) {
|
||||||
long count = repository.countByUserIdAndUserType(userId, userType);
|
long count = repository.countByUserIdAndUserType(userId, userType);
|
||||||
if (count >= 3) {
|
if (count >= 3) {
|
||||||
throw new IllegalStateException("Maximum limit of 3 API keys reached");
|
throw new IllegalStateException("Maximum limit of 3 API keys reached");
|
||||||
}
|
}
|
||||||
|
if (appId == null) {
|
||||||
|
throw new IllegalStateException("App ID is required for Customer keys");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DeveloperApiKey key = new DeveloperApiKey();
|
DeveloperApiKey key = new DeveloperApiKey();
|
||||||
@@ -39,6 +47,7 @@ public class DeveloperApiKeyService {
|
|||||||
key.setName(name);
|
key.setName(name);
|
||||||
key.setWriteAccess(writeAccess);
|
key.setWriteAccess(writeAccess);
|
||||||
key.setPermissions(permissions);
|
key.setPermissions(permissions);
|
||||||
|
key.setAppId(appId);
|
||||||
|
|
||||||
// Generate a secure API key
|
// Generate a secure API key
|
||||||
String prefix = writeAccess ? "DEV-W-" : "DEV-";
|
String prefix = writeAccess ? "DEV-W-" : "DEV-";
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Positeasy Clone Frontend (Counter/Admin)
|
# Tillo Frontend (Counter/Admin)
|
||||||
|
|
||||||
## 🔐 Security Hardening & API Migration (Branch: krishna)
|
## 🔐 Security Hardening & API Migration (Branch: krishna)
|
||||||
|
|
||||||
This branch contains critical security updates and infrastructure changes to support JWT-based authentication across the Positeasy ecosystem.
|
This branch contains critical security updates and infrastructure changes to support JWT-based authentication across the Tillo ecosystem.
|
||||||
|
|
||||||
### Key Changes:
|
### Key Changes:
|
||||||
- **Authenticated API Wrapper**: Migrated from standard `fetch` to a centralized `src/api.ts` wrapper. This wrapper automatically handles:
|
- **Authenticated API Wrapper**: Migrated from standard `fetch` to a centralized `src/api.ts` wrapper. This wrapper automatically handles:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class DeploymentReport(FPDF):
|
|||||||
self.set_font('Arial', 'B', 16)
|
self.set_font('Arial', 'B', 16)
|
||||||
self.cell(0, 20, 'Deployment Analysis Report', ln=True, align='C')
|
self.cell(0, 20, 'Deployment Analysis Report', ln=True, align='C')
|
||||||
self.set_font('Arial', '', 12)
|
self.set_font('Arial', '', 12)
|
||||||
self.cell(0, 10, 'Positeasy Clone - Canteen Automation Ecosystem', ln=True, align='C')
|
self.cell(0, 10, 'Tillo - Canteen Automation Ecosystem', ln=True, align='C')
|
||||||
self.ln(20)
|
self.ln(20)
|
||||||
|
|
||||||
def footer(self):
|
def footer(self):
|
||||||
@@ -107,4 +107,4 @@ ops_text = (
|
|||||||
)
|
)
|
||||||
pdf.section_body(ops_text)
|
pdf.section_body(ops_text)
|
||||||
|
|
||||||
pdf.output("Deployment_Report_Positeasy.pdf")
|
pdf.output("Deployment_Report_Tillo.pdf")
|
||||||
|
|||||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "Positeasy-Clone",
|
"name": "Tillo-Clone",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {}
|
"packages": {}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Test QR — Positeasy</title>
|
<title>Test QR — Tillo</title>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
/* ── Screen styles ── */
|
/* ── Screen styles ── */
|
||||||
@@ -294,7 +294,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="canteen-name">RIT Canteen</span>
|
<span class="canteen-name">RIT Canteen</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="subtitle">Positeasy · Order Receipt</span>
|
<span class="subtitle">Tillo · Order Receipt</span>
|
||||||
<div class="test-badge">
|
<div class="test-badge">
|
||||||
⚠️ TEST ORDER — NOT REAL
|
⚠️ TEST ORDER — NOT REAL
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user