Files
kiddo/backend/templates/rules.html
stephan 63ee0b40b5 feat: login time window rules (EPIC_000014)
- Implemented RuleManager and JSON storage
- Added background enforcement scheduler
- Added Web UI for rule management at /ui/rules
- Bumped version to 0.3.0
2026-01-16 11:25:56 +01:00

442 lines
16 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Regelverwaltung - Safe Kiddo</title>
<link rel="stylesheet" href="/static/styles.css" />
<link rel="icon" type="image/svg+xml" href="/assets/branding/favicon.svg" />
<script src="https://unpkg.com/lucide@latest"></script>
<style>
.rule-editor {
display: grid;
grid-template-columns: 250px 1fr;
gap: 1.5rem;
align-items: start;
}
.user-list {
background: var(--bg-panel);
border: 1px solid var(--border-main);
border-radius: var(--radius-sm);
overflow: hidden;
}
.user-item {
padding: 0.75rem 1rem;
cursor: pointer;
border-bottom: 1px solid var(--border-main);
display: flex;
justify-content: space-between;
align-items: center;
}
.user-item:last-child { border-bottom: none; }
.user-item:hover { background: rgba(255,255,255,0.05); }
.user-item.active { background: rgba(var(--primary-rgb), 0.1); border-left: 3px solid var(--primary); }
.editor-panel {
background: var(--bg-panel);
border: 1px solid var(--border-main);
border-radius: var(--radius-sm);
padding: 1.5rem;
}
.window-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
}
.window-item {
background: var(--bg-main);
border: 1px solid var(--border-main);
padding: 0.75rem;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.day-badges {
display: flex;
gap: 0.25rem;
}
.day-badge {
font-size: 0.7rem;
padding: 2px 6px;
border-radius: 4px;
background: var(--bg-panel);
border: 1px solid var(--border-main);
color: var(--text-muted);
}
.day-badge.active {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
.time-range {
font-family: var(--font-mono);
font-weight: 600;
}
.remove-btn {
margin-left: auto;
color: var(--color-error);
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
}
.day-selector {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.day-checkbox {
display: none;
}
.day-label {
padding: 0.25rem 0.5rem;
border: 1px solid var(--border-main);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
user-select: none;
}
.day-checkbox:checked + .day-label {
background: var(--primary);
color: white;
border-color: var(--primary);
}
</style>
</head>
<body>
<!-- Dark Mode Toggle -->
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<div class="header-main">
<h1>
<i data-lucide="clock"></i>
Regelverwaltung
</h1>
</div>
<div class="header-meta">
<a href="/dashboard" class="button secondary small">
<i data-lucide="arrow-left"></i> Zurück zum Dashboard
</a>
</div>
</header>
<div class="rule-editor" id="ruleEditor">
<!-- User List -->
<div class="user-list" id="userList">
<div style="padding: 1rem; text-align: center; color: var(--text-muted);">
Lade Nutzer...
</div>
</div>
<!-- Editor Panel -->
<div class="editor-panel hidden" id="editorPanel">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h2 id="selectedUserTitle" style="margin: 0;">Benutzer</h2>
<div style="display: flex; gap: 0.5rem;">
<button class="danger small" id="deleteRulesBtn">Regeln löschen</button>
<button class="primary small" id="saveRulesBtn">Speichern</button>
</div>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="ruleEnabled" checked>
<label for="ruleEnabled">Regelwerk aktiv</label>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="autoReenable">
<label for="autoReenable">Automatisch entsperren (wenn Zeitfenster beginnt)</label>
</div>
<h3 style="margin-top: 1.5rem; margin-bottom: 0.5rem;">Erlaubte Zeitfenster</h3>
<div class="window-list" id="windowList">
<!-- Windows go here -->
</div>
<button class="secondary small mt-1" onclick="openAddWindowModal()">
<i data-lucide="plus"></i> Zeitfenster hinzufügen
</button>
</div>
<div class="editor-panel" id="emptyState" style="text-align: center; padding: 3rem;">
<i data-lucide="user" style="width: 48px; height: 48px; color: var(--text-muted); margin-bottom: 1rem;"></i>
<p class="text-muted">Wähle einen Benutzer aus, um Regeln zu bearbeiten.</p>
</div>
</div>
</div>
<!-- Add Window Modal -->
<div class="modal" id="addWindowModal">
<div class="modal-content">
<div class="modal-header">
<h3>Zeitfenster hinzufügen</h3>
<button class="secondary small" onclick="closeAddWindowModal()">
<i data-lucide="x"></i>
</button>
</div>
<form id="addWindowForm">
<div class="form-group">
<label>Wochentage</label>
<div class="day-selector">
<label><input type="checkbox" class="day-checkbox" value="mon"><span class="day-label">Mo</span></label>
<label><input type="checkbox" class="day-checkbox" value="tue"><span class="day-label">Di</span></label>
<label><input type="checkbox" class="day-checkbox" value="wed"><span class="day-label">Mi</span></label>
<label><input type="checkbox" class="day-checkbox" value="thu"><span class="day-label">Do</span></label>
<label><input type="checkbox" class="day-checkbox" value="fri"><span class="day-label">Fr</span></label>
<label><input type="checkbox" class="day-checkbox" value="sat"><span class="day-label">Sa</span></label>
<label><input type="checkbox" class="day-checkbox" value="sun"><span class="day-label">So</span></label>
</div>
</div>
<div class="grid">
<div class="form-group">
<label>Start</label>
<input type="time" id="startTime" required>
</div>
<div class="form-group">
<label>Ende</label>
<input type="time" id="endTime" required>
</div>
</div>
<div class="modal-actions">
<button type="button" class="secondary" onclick="closeAddWindowModal()">Abbrechen</button>
<button type="submit">Hinzufügen</button>
</div>
</form>
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div>
<script>
lucide.createIcons();
// State
let users = [];
let currentRules = {}; // Keyed by username
let selectedUser = null;
let currentWindows = []; // Temp storage for editor
// API
async function api(path, options = {}) {
const token = sessionStorage.getItem('skdToken');
if (!token) window.location.href = '/login';
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
...(options.headers || {})
};
const res = await fetch(path, { ...options, headers });
if (!res.ok) throw new Error(await res.text());
return res.json();
}
function showToast(message, type = 'success') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<i data-lucide="${type === 'success' ? 'check-circle' : 'alert-circle'}"></i>
<span>${message}</span>
`;
container.appendChild(toast);
lucide.createIcons();
setTimeout(() => {
toast.style.animation = 'slideIn 0.3s ease reverse';
setTimeout(() => toast.remove(), 300);
}, 4000);
}
// Init
async function init() {
try {
// Load users and rules in parallel
const [usersData, rulesData] = await Promise.all([
api('/users'),
api('/rules')
]);
users = usersData.map(u => u.user);
currentRules = rulesData.rules || {};
renderUserList();
} catch (err) {
showToast('Fehler beim Laden: ' + err.message, 'error');
}
}
function renderUserList() {
const list = document.getElementById('userList');
if (users.length === 0) {
list.innerHTML = '<div style="padding:1rem;">Keine Nutzer gefunden.</div>';
return;
}
list.innerHTML = users.map(u => {
const hasRule = !!currentRules[u];
return `
<div class="user-item ${selectedUser === u ? 'active' : ''}" onclick="selectUser('${u}')">
<div>
<strong>${u}</strong>
${hasRule ? '<i data-lucide="check" style="width:14px; height:14px; margin-left:4px; color:var(--color-success)"></i>' : ''}
</div>
<i data-lucide="chevron-right" style="width:16px; height:16px; color:var(--text-muted)"></i>
</div>
`;
}).join('');
lucide.createIcons();
}
function selectUser(user) {
selectedUser = user;
renderUserList(); // Update active state
document.getElementById('emptyState').classList.add('hidden');
document.getElementById('editorPanel').classList.remove('hidden');
document.getElementById('selectedUserTitle').textContent = user;
const rule = currentRules[user] || { enabled: true, auto_reenable: false, allowed_windows: [] };
document.getElementById('ruleEnabled').checked = rule.enabled;
document.getElementById('autoReenable').checked = rule.auto_reenable;
currentWindows = [...(rule.allowed_windows || [])];
renderWindows();
}
function renderWindows() {
const container = document.getElementById('windowList');
if (currentWindows.length === 0) {
container.innerHTML = '<div class="text-muted" style="font-size:0.9rem;">Keine Zeitfenster definiert. Login ist standardmäßig <strong>nicht erlaubt</strong> (wenn Regelwerk aktiv).</div>';
return;
}
const dayMap = { mon:'Mo', tue:'Di', wed:'Mi', thu:'Do', fri:'Fr', sat:'Sa', sun:'So' };
const allDays = ['mon','tue','wed','thu','fri','sat','sun'];
container.innerHTML = currentWindows.map((win, idx) => {
const badges = allDays.map(d => `
<span class="day-badge ${win.days.includes(d) ? 'active' : ''}">${dayMap[d]}</span>
`).join('');
return `
<div class="window-item">
<div class="day-badges">${badges}</div>
<div class="time-range">
${win.start_time} - ${win.end_time}
</div>
<button class="remove-btn" onclick="removeWindow(${idx})">
<i data-lucide="trash-2" style="width:16px; height:16px;"></i>
</button>
</div>
`;
}).join('');
lucide.createIcons();
}
function removeWindow(index) {
currentWindows.splice(index, 1);
renderWindows();
}
function openAddWindowModal() {
document.getElementById('addWindowForm').reset();
// Default checkboxes
document.querySelectorAll('.day-checkbox').forEach(cb => {
if (['mon','tue','wed','thu','fri'].includes(cb.value)) cb.checked = true;
else cb.checked = false;
});
document.getElementById('addWindowModal').classList.add('active');
}
function closeAddWindowModal() {
document.getElementById('addWindowModal').classList.remove('active');
}
document.getElementById('addWindowForm').addEventListener('submit', (e) => {
e.preventDefault();
const days = Array.from(document.querySelectorAll('.day-checkbox:checked')).map(cb => cb.value);
if (days.length === 0) {
showToast('Bitte mindestens einen Wochentag wählen.', 'error');
return;
}
const start = document.getElementById('startTime').value;
const end = document.getElementById('endTime').value;
if (start >= end) {
showToast('Endzeit muss nach Startzeit liegen.', 'error');
return;
}
currentWindows.push({ days, start_time: start, end_time: end });
currentWindows.sort((a, b) => a.start_time.localeCompare(b.start_time));
closeAddWindowModal();
renderWindows();
});
document.getElementById('saveRulesBtn').addEventListener('click', async () => {
if (!selectedUser) return;
const rule = {
user: selectedUser,
enabled: document.getElementById('ruleEnabled').checked,
auto_reenable: document.getElementById('autoReenable').checked,
allowed_windows: currentWindows
};
try {
await api('/rules', {
method: 'POST',
body: JSON.stringify(rule)
});
currentRules[selectedUser] = rule;
showToast('Regeln gespeichert', 'success');
renderUserList();
} catch (err) {
showToast('Speichern fehlgeschlagen: ' + err.message, 'error');
}
});
document.getElementById('deleteRulesBtn').addEventListener('click', async () => {
if (!selectedUser || !confirm(`Regeln für ${selectedUser} wirklich löschen?\nDer Nutzer hat dann uneingeschränkten Zugriff.`)) return;
try {
await api(`/rules/${selectedUser}`, { method: 'DELETE' });
delete currentRules[selectedUser];
showToast('Regeln gelöscht', 'success');
selectUser(selectedUser); // Refresh view (defaults)
} catch (err) {
showToast('Löschen fehlgeschlagen: ' + err.message, 'error');
}
});
// Dark Mode (Shared)
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
init();
</script>
</body>
</html>