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
This commit is contained in:
442
backend/templates/rules.html
Normal file
442
backend/templates/rules.html
Normal file
@ -0,0 +1,442 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user