mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
Add persistent mobile tokens with idle sliding expiration and device revocation
This commit is contained in:
parent
e4b71fc5e4
commit
633eb4e63c
3 changed files with 311 additions and 6 deletions
|
|
@ -53,6 +53,8 @@ const elements = {
|
||||||
btnToggleGeminiKey: document.getElementById('btn-toggle-gemini-key'),
|
btnToggleGeminiKey: document.getElementById('btn-toggle-gemini-key'),
|
||||||
settingsStatusMsg: document.getElementById('settings-status-msg'),
|
settingsStatusMsg: document.getElementById('settings-status-msg'),
|
||||||
settingsStatusText: document.getElementById('settings-status-text'),
|
settingsStatusText: document.getElementById('settings-status-text'),
|
||||||
|
linkedDevicesList: document.getElementById('linked-devices-list'),
|
||||||
|
btnRefreshDevices: document.getElementById('btn-refresh-devices'),
|
||||||
scanImageInput: document.getElementById('scan-image-input'),
|
scanImageInput: document.getElementById('scan-image-input'),
|
||||||
btnScanImage: document.getElementById('btn-scan-image'),
|
btnScanImage: document.getElementById('btn-scan-image'),
|
||||||
recipesGrid: document.getElementById('recipes-grid'),
|
recipesGrid: document.getElementById('recipes-grid'),
|
||||||
|
|
@ -290,6 +292,9 @@ function registerEvents() {
|
||||||
if (elements.btnDiscoverModels) {
|
if (elements.btnDiscoverModels) {
|
||||||
elements.btnDiscoverModels.addEventListener('click', () => handleDiscoverModels(true));
|
elements.btnDiscoverModels.addEventListener('click', () => handleDiscoverModels(true));
|
||||||
}
|
}
|
||||||
|
if (elements.btnRefreshDevices) {
|
||||||
|
elements.btnRefreshDevices.addEventListener('click', loadLinkedDevices);
|
||||||
|
}
|
||||||
|
|
||||||
// Image Scanning Actions
|
// Image Scanning Actions
|
||||||
elements.btnScanImage.addEventListener('click', () => elements.scanImageInput.click());
|
elements.btnScanImage.addEventListener('click', () => elements.scanImageInput.click());
|
||||||
|
|
@ -2395,6 +2400,7 @@ async function openSettingsModal() {
|
||||||
}
|
}
|
||||||
|
|
||||||
openModal('settings-modal');
|
openModal('settings-modal');
|
||||||
|
loadLinkedDevices();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/settings', {
|
const response = await fetch('/api/settings', {
|
||||||
|
|
@ -2504,6 +2510,99 @@ function toggleSettingsKeyVisibility() {
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadLinkedDevices() {
|
||||||
|
const container = document.getElementById('linked-devices-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = '<div style="font-size: 0.85rem; color: var(--text-muted); font-style: italic; padding: 6px 0;">Loading linked devices...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/devices', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
logout();
|
||||||
|
closeModal('settings-modal');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch devices');
|
||||||
|
const devices = await res.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(devices) || devices.length === 0) {
|
||||||
|
container.innerHTML = '<div style="font-size: 0.85rem; color: var(--text-muted); padding: 8px 0;">No mobile devices currently linked.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
devices.forEach(d => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.style.cssText = 'display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; background: rgba(0,0,0,0.18); border: 1px solid var(--border-color); border-radius: var(--border-radius-md); gap: 12px;';
|
||||||
|
|
||||||
|
const left = document.createElement('div');
|
||||||
|
left.style.cssText = 'display: flex; flex-direction: column; gap: 3px; min-width: 0;';
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.style.cssText = 'font-size: 0.88rem; font-weight: 600; color: var(--text-main); display: flex; align-items: center; gap: 6px;';
|
||||||
|
|
||||||
|
const icon = document.createElement('i');
|
||||||
|
icon.setAttribute('data-lucide', 'smartphone');
|
||||||
|
icon.style.cssText = 'width: 14px; height: 14px; color: var(--primary); flex-shrink: 0;';
|
||||||
|
title.appendChild(icon);
|
||||||
|
|
||||||
|
const nameSpan = document.createElement('span');
|
||||||
|
nameSpan.style.cssText = 'overflow: hidden; text-overflow: ellipsis; white-space: nowrap;';
|
||||||
|
nameSpan.textContent = d.deviceName || 'Mobile App';
|
||||||
|
title.appendChild(nameSpan);
|
||||||
|
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.style.cssText = 'font-size: 0.75rem; color: var(--text-muted);';
|
||||||
|
const lastActive = d.lastUsedAt ? new Date(d.lastUsedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : 'Never';
|
||||||
|
meta.textContent = 'Last active: ' + lastActive;
|
||||||
|
|
||||||
|
left.appendChild(title);
|
||||||
|
left.appendChild(meta);
|
||||||
|
|
||||||
|
const btnRevoke = document.createElement('button');
|
||||||
|
btnRevoke.type = 'button';
|
||||||
|
btnRevoke.className = 'btn btn-secondary btn-sm';
|
||||||
|
btnRevoke.style.cssText = 'font-size: 0.75rem; padding: 4px 10px; color: #ef4444; border-color: rgba(239, 68, 68, 0.35); flex-shrink: 0; cursor: pointer;';
|
||||||
|
btnRevoke.textContent = 'Revoke';
|
||||||
|
btnRevoke.onclick = () => revokeDevice(d.id, d.deviceName);
|
||||||
|
|
||||||
|
item.appendChild(left);
|
||||||
|
item.appendChild(btnRevoke);
|
||||||
|
container.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (window.lucide && lucide.createIcons) {
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading devices:', err);
|
||||||
|
container.innerHTML = '<div style="font-size: 0.85rem; color: #ef4444; padding: 6px 0;">Could not load linked devices.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeDevice(deviceId, deviceName) {
|
||||||
|
if (!confirm('Are you sure you want to revoke access for "' + (deviceName || 'this device') + '"? The mobile app will be logged out immediately.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/devices/' + deviceId, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || 'Failed to revoke device');
|
||||||
|
}
|
||||||
|
await loadLinkedDevices();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Could not revoke device: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function showScanLoading() {
|
function showScanLoading() {
|
||||||
elements.loadingOverlay.classList.add('active');
|
elements.loadingOverlay.classList.add('active');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1116,7 +1116,26 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SECTION 3: CULINARY OPTIONS (COMING SOON) -->
|
<!-- SECTION 3: LINKED MOBILE DEVICES -->
|
||||||
|
<div class="settings-section" style="margin-bottom: 24px;">
|
||||||
|
<div class="settings-section-title" style="display: flex; align-items: center; justify-content: space-between; font-weight: 600; font-size: 1.05rem; border-bottom: 1px solid var(--border-color); padding-bottom: 8px; margin-bottom: 12px; color: var(--text-main);">
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<i data-lucide="smartphone" style="color: var(--primary); width: 18px; height: 18px;"></i>
|
||||||
|
<span>Linked Mobile Devices</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="btn-refresh-devices" class="btn btn-secondary btn-sm" style="font-size: 0.75rem; padding: 2px 10px; height: 28px; display: inline-flex; align-items: center; gap: 5px; cursor: pointer;" title="Refresh list of linked devices">
|
||||||
|
<i data-lucide="refresh-cw" style="width: 13px; height: 13px;"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 0.85rem; line-height: 1.4; color: var(--text-muted); margin-bottom: 12px;">
|
||||||
|
Mobile apps authorized with persistent tokens. Devices inactive for 90 days expire automatically.
|
||||||
|
</p>
|
||||||
|
<div id="linked-devices-list" style="display: flex; flex-direction: column; gap: 8px;">
|
||||||
|
<div style="font-size: 0.85rem; color: var(--text-muted); font-style: italic; padding: 8px 0;">Loading linked devices...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SECTION 4: CULINARY OPTIONS (COMING SOON) -->
|
||||||
<div class="settings-section" style="opacity: 0.55; margin-bottom: 24px;">
|
<div class="settings-section" style="opacity: 0.55; margin-bottom: 24px;">
|
||||||
<div class="settings-section-title" style="display: flex; align-items: center; justify-content: space-between; font-weight: 600; font-size: 1.05rem; border-bottom: 1px solid var(--border-color); padding-bottom: 8px; margin-bottom: 12px; color: var(--text-main);">
|
<div class="settings-section-title" style="display: flex; align-items: center; justify-content: space-between; font-weight: 600; font-size: 1.05rem; border-bottom: 1px solid var(--border-color); padding-bottom: 8px; margin-bottom: 12px; color: var(--text-main);">
|
||||||
<div style="display: flex; align-items: center; gap: 8px;">
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
|
|
||||||
197
server.js
197
server.js
|
|
@ -216,6 +216,22 @@ try {
|
||||||
db.prepare("UPDATE settings SET geminiModel = 'gemini-2.5-flash' WHERE geminiModel IS NULL OR geminiModel = '' OR geminiModel = 'gemini-2.0-flash'").run();
|
db.prepare("UPDATE settings SET geminiModel = 'gemini-2.5-flash' WHERE geminiModel IS NULL OR geminiModel = '' OR geminiModel = 'gemini-2.0-flash'").run();
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
|
// Initialize persistent mobile tokens table
|
||||||
|
db.prepare(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_mobile_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
tokenHash TEXT NOT NULL UNIQUE,
|
||||||
|
deviceName TEXT NOT NULL,
|
||||||
|
createdAt TEXT NOT NULL,
|
||||||
|
lastUsedAt TEXT NOT NULL,
|
||||||
|
expiresAt TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`).run();
|
||||||
|
db.prepare('CREATE INDEX IF NOT EXISTS idx_user_mobile_tokens_hash ON user_mobile_tokens(tokenHash)').run();
|
||||||
|
db.prepare('CREATE INDEX IF NOT EXISTS idx_user_mobile_tokens_user ON user_mobile_tokens(userId)').run();
|
||||||
|
|
||||||
// Initialize single settings record if empty
|
// Initialize single settings record if empty
|
||||||
const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count;
|
const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count;
|
||||||
if (settingsCount === 0) {
|
if (settingsCount === 0) {
|
||||||
|
|
@ -457,19 +473,70 @@ function hashPassword(password, salt) {
|
||||||
return crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
|
return crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middleware: Authenticate Session Token
|
// Middleware: Authenticate Session Token (in-memory web session or persistent mobile token)
|
||||||
function requireAuth(req, res, next) {
|
function requireAuth(req, res, next) {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
return res.status(401).json({ error: 'Authorization required. Please log in.' });
|
return res.status(401).json({ error: 'Authorization required. Please log in.' });
|
||||||
}
|
}
|
||||||
const token = authHeader.substring(7);
|
const token = authHeader.substring(7);
|
||||||
|
|
||||||
|
// 1. Check in-memory web session first
|
||||||
const session = activeSessions.get(token);
|
const session = activeSessions.get(token);
|
||||||
if (!session) {
|
if (session) {
|
||||||
return res.status(401).json({ error: 'Session expired. Please log in again.' });
|
req.user = session;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check persistent mobile device token
|
||||||
|
try {
|
||||||
|
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT t.id AS tokenId, t.userId, t.deviceName, t.lastUsedAt, t.expiresAt,
|
||||||
|
u.username, u.isAdmin, u.isRecipeManager, u.defaultConsolidateLists
|
||||||
|
FROM user_mobile_tokens t
|
||||||
|
JOIN users u ON t.userId = u.id
|
||||||
|
WHERE t.tokenHash = ?
|
||||||
|
`).get(tokenHash);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(401).json({ error: 'Session expired or invalid. Please log in again.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check idle expiration
|
||||||
|
const now = new Date();
|
||||||
|
if (new Date(row.expiresAt) < now) {
|
||||||
|
db.prepare('DELETE FROM user_mobile_tokens WHERE id = ?').run(row.tokenId);
|
||||||
|
return res.status(401).json({ error: 'Session expired due to inactivity. Please log in again.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slide expiration forward (90 days inactivity threshold)
|
||||||
|
// To minimize write overhead, only update if lastUsedAt was more than 15 minutes ago
|
||||||
|
const lastUsed = new Date(row.lastUsedAt);
|
||||||
|
if (now.getTime() - lastUsed.getTime() > 15 * 60 * 1000) {
|
||||||
|
const newExpiresAt = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE user_mobile_tokens
|
||||||
|
SET lastUsedAt = ?, expiresAt = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(now.toISOString(), newExpiresAt, row.tokenId);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = {
|
||||||
|
id: row.userId,
|
||||||
|
username: row.username,
|
||||||
|
isAdmin: row.isAdmin === 1,
|
||||||
|
isRecipeManager: row.isRecipeManager === 1,
|
||||||
|
defaultConsolidateLists: row.defaultConsolidateLists !== 0,
|
||||||
|
isMobileToken: true,
|
||||||
|
deviceId: row.tokenId,
|
||||||
|
deviceName: row.deviceName
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Error verifying mobile token in requireAuth:', err);
|
||||||
|
return res.status(500).json({ error: 'Internal authentication error.' });
|
||||||
}
|
}
|
||||||
req.user = session;
|
|
||||||
next();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Deserialize JSON columns from SQLite recipe row
|
// Helper: Deserialize JSON columns from SQLite recipe row
|
||||||
|
|
@ -598,6 +665,126 @@ app.post('/api/auth/login', (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auth API: Mobile App Login (Issues persistent, hashed device token with 90-day idle expiration)
|
||||||
|
app.post('/api/auth/mobile-login', (req, res) => {
|
||||||
|
const { username, password, deviceName } = req.body;
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({ error: 'Username and password are required.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanUsername = username.trim().toLowerCase();
|
||||||
|
try {
|
||||||
|
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(cleanUsername);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: 'Invalid username or password.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = hashPassword(password, user.salt);
|
||||||
|
if (hash !== user.passwordHash) {
|
||||||
|
return res.status(401).json({ error: 'Invalid username or password.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate cryptographic device token and SHA-256 hash
|
||||||
|
const rawToken = 'ff_m_' + crypto.randomBytes(32).toString('hex');
|
||||||
|
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||||
|
const tokenId = crypto.randomUUID();
|
||||||
|
const now = new Date();
|
||||||
|
const expiresAt = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
const cleanDeviceName = (deviceName && typeof deviceName === 'string' ? deviceName.trim().slice(0, 80) : 'Mobile Device') || 'Mobile Device';
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO user_mobile_tokens (id, userId, tokenHash, deviceName, createdAt, lastUsedAt, expiresAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(tokenId, user.id, tokenHash, cleanDeviceName, now.toISOString(), now.toISOString(), expiresAt);
|
||||||
|
|
||||||
|
log.info(`[Mobile Auth] Issued persistent token for user '${user.username}' on device '${cleanDeviceName}'`);
|
||||||
|
|
||||||
|
const defaultConsolidateLists = user.defaultConsolidateLists !== 0;
|
||||||
|
res.json({
|
||||||
|
token: rawToken,
|
||||||
|
expiresAt,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
isAdmin: user.isAdmin === 1,
|
||||||
|
isRecipeManager: user.isRecipeManager === 1,
|
||||||
|
defaultConsolidateLists
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Error during mobile login:', error);
|
||||||
|
res.status(500).json({ error: 'Mobile login failed.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth API: List Linked Mobile Devices
|
||||||
|
app.get('/api/auth/devices', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT id, deviceName, createdAt, lastUsedAt, expiresAt
|
||||||
|
FROM user_mobile_tokens
|
||||||
|
WHERE userId = ?
|
||||||
|
ORDER BY lastUsedAt DESC
|
||||||
|
`).all(req.user.id);
|
||||||
|
|
||||||
|
const devices = rows.map(d => ({
|
||||||
|
id: d.id,
|
||||||
|
deviceName: d.deviceName,
|
||||||
|
createdAt: d.createdAt,
|
||||||
|
lastUsedAt: d.lastUsedAt,
|
||||||
|
expiresAt: d.expiresAt,
|
||||||
|
isCurrentDevice: req.user.isMobileToken && req.user.deviceId === d.id
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(devices);
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Error listing linked mobile devices:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to retrieve linked devices.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth API: Revoke Linked Mobile Device
|
||||||
|
app.delete('/api/auth/devices/:id', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const targetId = req.params.id;
|
||||||
|
// Allow owner or system admin to revoke
|
||||||
|
const tokenRecord = db.prepare('SELECT id, userId, deviceName FROM user_mobile_tokens WHERE id = ?').get(targetId);
|
||||||
|
if (!tokenRecord) {
|
||||||
|
return res.status(404).json({ error: 'Device record not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenRecord.userId !== req.user.id && !req.user.isAdmin) {
|
||||||
|
return res.status(403).json({ error: 'Access denied. You cannot revoke another user\'s device.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM user_mobile_tokens WHERE id = ?').run(targetId);
|
||||||
|
log.info(`[Mobile Auth] Revoked device '${tokenRecord.deviceName}' (${targetId}) by user '${req.user.username}'`);
|
||||||
|
res.json({ success: true, message: `Device '${tokenRecord.deviceName}' has been revoked.` });
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Error revoking device:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to revoke device.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth API: Logout
|
||||||
|
app.post('/api/auth/logout', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.user.isMobileToken && req.user.deviceId) {
|
||||||
|
db.prepare('DELETE FROM user_mobile_tokens WHERE id = ?').run(req.user.deviceId);
|
||||||
|
} else {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
activeSessions.delete(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: 'Logged out successfully.' });
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Error during logout:', error);
|
||||||
|
res.status(500).json({ error: 'Logout failed.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Admin API: List all users (Requires Admin session)
|
// Admin API: List all users (Requires Admin session)
|
||||||
app.get('/api/admin/users', requireAuth, (req, res) => {
|
app.get('/api/admin/users', requireAuth, (req, res) => {
|
||||||
if (!req.user.isAdmin) {
|
if (!req.user.isAdmin) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue