mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
- Device WebSocket authentication: devices get a device_token on registration, must present it on reconnect. All WS events require prior auth. Timing-safe token comparison. - IDOR fixes: ownership checks on schedules (device, week), layouts (all CRUD, zones, duplicate, device assign), video-walls (content, device-config). - XSS prevention: shared esc() helper in utils.js, fixed 13 innerHTML injection points across 9 frontend files. - OAuth hardening: no longer silently overwrites auth_provider on accounts with local passwords (returns 409). - JWT pinned to HS256 for sign and verify. - Password policy: change endpoint now requires 8 chars (was 6). - HSTS header enabled (max-age 1 year, includeSubDomains). - Stripe webhook rejects unsigned payloads when no secret configured. - Screenshot size validation (max 2MB base64). - Rate limiting on exports, imports, content operations. - Content file serving checks playlist_items instead of old assignments. - Content ownership verified in device-groups assign-content. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
103 lines
4 KiB
JavaScript
103 lines
4 KiB
JavaScript
import { showToast } from '../components/toast.js';
|
|
import { esc } from '../utils.js';
|
|
|
|
const API = (url) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}).then(r => r.json());
|
|
|
|
export async function render(container) {
|
|
container.innerHTML = `
|
|
<div class="page-header">
|
|
<div><h1>Activity Log</h1><div class="subtitle">Audit trail of all actions</div></div>
|
|
</div>
|
|
<div id="activityList"><div class="empty-state"><h3>Loading...</h3></div></div>
|
|
<div style="text-align:center;margin-top:16px">
|
|
<button class="btn btn-secondary btn-sm" id="loadMoreBtn" style="display:none">Load More</button>
|
|
</div>
|
|
`;
|
|
|
|
let offset = 0;
|
|
const limit = 50;
|
|
|
|
async function loadActivity(append = false) {
|
|
try {
|
|
const items = await API(`/activity?limit=${limit}&offset=${offset}`);
|
|
const list = document.getElementById('activityList');
|
|
|
|
if (!append) list.innerHTML = '';
|
|
|
|
if (items.length === 0 && offset === 0) {
|
|
list.innerHTML = '<div class="empty-state"><h3>No activity yet</h3><p>Actions will appear here as you use the system.</p></div>';
|
|
return;
|
|
}
|
|
|
|
const html = items.map(item => {
|
|
const time = new Date(item.created_at * 1000);
|
|
const timeStr = time.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' +
|
|
time.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
|
const icon = getActionIcon(item.action);
|
|
|
|
return `
|
|
<div style="display:flex;gap:12px;padding:12px 0;border-bottom:1px solid var(--border);align-items:flex-start">
|
|
<div style="width:32px;height:32px;border-radius:50%;background:var(--bg-card);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:14px">${icon}</div>
|
|
<div style="flex:1;min-width:0">
|
|
<div style="font-size:13px">
|
|
<strong>${esc(item.user_name || item.user_email || 'System')}</strong>
|
|
<span style="color:var(--text-secondary)"> ${esc(formatAction(item.action))}</span>
|
|
</div>
|
|
${item.details ? `<div style="font-size:12px;color:var(--text-muted);margin-top:2px">${esc(item.details)}</div>` : ''}
|
|
</div>
|
|
<div style="font-size:11px;color:var(--text-muted);white-space:nowrap;flex-shrink:0">${timeStr}</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
if (append) {
|
|
list.insertAdjacentHTML('beforeend', html);
|
|
} else {
|
|
list.innerHTML = html;
|
|
}
|
|
|
|
document.getElementById('loadMoreBtn').style.display = items.length >= limit ? '' : 'none';
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
}
|
|
|
|
document.getElementById('loadMoreBtn').onclick = () => {
|
|
offset += limit;
|
|
loadActivity(true);
|
|
};
|
|
|
|
loadActivity();
|
|
}
|
|
|
|
function getActionIcon(action) {
|
|
if (action.includes('DELETE')) return '🗑';
|
|
if (action.includes('POST') && action.includes('content')) return '📤';
|
|
if (action.includes('POST') && action.includes('provision')) return '🔗';
|
|
if (action.includes('POST') && action.includes('assignment')) return '📋';
|
|
if (action.includes('alert')) return '🔔';
|
|
if (action.includes('PUT')) return '✎';
|
|
if (action.includes('POST')) return '➕';
|
|
return '📄';
|
|
}
|
|
|
|
function formatAction(action) {
|
|
return action
|
|
.replace('POST /api/', 'created ')
|
|
.replace('PUT /api/', 'updated ')
|
|
.replace('DELETE /api/', 'deleted ')
|
|
.replace('/provision/pair', 'paired a device')
|
|
.replace('/content/remote', 'added remote content')
|
|
.replace('/content', 'content')
|
|
.replace('/devices/:id', 'device')
|
|
.replace('/assignments/device/:deviceId', 'playlist assignment')
|
|
.replace('/assignments/:id', 'assignment')
|
|
.replace('/layouts', 'layout')
|
|
.replace('/widgets', 'widget')
|
|
.replace('/schedules', 'schedule')
|
|
.replace('/walls', 'video wall')
|
|
.replace('alert:device_offline', 'alert: device went offline');
|
|
}
|
|
|
|
export function cleanup() {}
|