Updated API Dashboard

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -92,6 +92,7 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.PUT, "/api/orders/*").authenticated()
.requestMatchers(HttpMethod.POST, "/api/orders/*/cancel").authenticated()
.requestMatchers("/api/developer-keys/**").authenticated()
.requestMatchers("/api/developer-apps/**").authenticated()
.requestMatchers("/api/developer/v1/**").permitAll()
.requestMatchers("/api/counter/**").hasAnyRole("MASTER", "MANAGER", "STAFF")

View File

@@ -1,13 +1,18 @@
package com.rit.canteen.sales.config;
import org.springframework.beans.factory.annotation.Autowired;
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
public class WebConfig {
// CORS handled by SecurityConfig — do not add CorsRegistry here
public class WebConfig implements WebMvcConfigurer {
@Autowired
private DeveloperApiLogInterceptor logInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(logInterceptor).addPathPatterns("/api/developer/v1/**");
}
}

View File

@@ -9,6 +9,9 @@ import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
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.Map;
@@ -19,6 +22,30 @@ public class DeveloperApiKeyController {
@Autowired
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
public ResponseEntity<?> listKeys() {
UserContext context = getUserContext();
@@ -56,8 +83,17 @@ public class DeveloperApiKeyController {
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 {
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);
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));

View File

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

View File

@@ -40,6 +40,9 @@ public class DeveloperApiKey {
private LocalDateTime lastUsedAt;
@Column(nullable = true)
private Long appId;
public DeveloperApiKey() {}
@PrePersist
@@ -134,4 +137,12 @@ public class DeveloperApiKey {
public void setPermissions(String permissions) {
this.permissions = permissions;
}
public Long getAppId() {
return appId;
}
public void setAppId(Long appId) {
this.appId = appId;
}
}

View File

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

View File

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

View File

@@ -9,4 +9,6 @@ public interface DeveloperApiKeyRepository extends JpaRepository<DeveloperApiKey
Optional<DeveloperApiKey> findByApiKey(String apiKey);
List<DeveloperApiKey> findByUserIdAndUserType(Long userId, String userType);
long countByUserIdAndUserType(Long userId, String userType);
List<DeveloperApiKey> findByAppId(Long appId);
void deleteByAppId(Long appId);
}

View File

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

View File

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

View File

@@ -24,12 +24,20 @@ public class DeveloperApiKeyService {
@Transactional
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.
if (!"SYSTEM".equalsIgnoreCase(userType)) {
long count = repository.countByUserIdAndUserType(userId, userType);
if (count >= 3) {
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();
@@ -39,6 +47,7 @@ public class DeveloperApiKeyService {
key.setName(name);
key.setWriteAccess(writeAccess);
key.setPermissions(permissions);
key.setAppId(appId);
// Generate a secure API key
String prefix = writeAccess ? "DEV-W-" : "DEV-";

View File

@@ -1,8 +1,8 @@
# Positeasy Clone Frontend (Counter/Admin)
# Tillo Frontend (Counter/Admin)
## 🔐 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:
- **Authenticated API Wrapper**: Migrated from standard `fetch` to a centralized `src/api.ts` wrapper. This wrapper automatically handles:

View File

@@ -8,7 +8,7 @@ class DeploymentReport(FPDF):
self.set_font('Arial', 'B', 16)
self.cell(0, 20, 'Deployment Analysis Report', ln=True, align='C')
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)
def footer(self):
@@ -107,4 +107,4 @@ ops_text = (
)
pdf.section_body(ops_text)
pdf.output("Deployment_Report_Positeasy.pdf")
pdf.output("Deployment_Report_Tillo.pdf")

2
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{
"name": "Positeasy-Clone",
"name": "Tillo-Clone",
"lockfileVersion": 3,
"requires": true,
"packages": {}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<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>
<style>
/* ── Screen styles ── */
@@ -294,7 +294,7 @@
</div>
<span class="canteen-name">RIT Canteen</span>
</div>
<span class="subtitle">Positeasy · Order Receipt</span>
<span class="subtitle">Tillo · Order Receipt</span>
<div class="test-badge">
⚠️ TEST ORDER — NOT REAL
</div>