feat: HA-Entitaeten fuer Push-Erinnerungen und Kanban; neues Feature Sidebar-Sichtbarkeit pro Nutzer
This commit is contained in:
@@ -258,6 +258,8 @@ if (!userCols2.includes('hidden'))
|
||||
db.exec("ALTER TABLE users ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0");
|
||||
if (!userCols2.includes('chat_active_at'))
|
||||
db.exec("ALTER TABLE users ADD COLUMN chat_active_at DATETIME DEFAULT NULL");
|
||||
if (!userCols2.includes('hidden_tools'))
|
||||
db.exec("ALTER TABLE users ADD COLUMN hidden_tools TEXT NOT NULL DEFAULT '[]'");
|
||||
|
||||
// ── Login-Sicherheit ──────────────────────────────────────────────────────────
|
||||
// Spalten für Account-Lockout in users-Tabelle
|
||||
|
||||
@@ -302,6 +302,8 @@ async function runScheduler() {
|
||||
mqttClient.publishPresence();
|
||||
mqttClient.publishMediaAnfragen();
|
||||
mqttClient.publishDruckStatistik();
|
||||
mqttClient.publishPushErinnerungen();
|
||||
mqttClient.publishKanban();
|
||||
try {
|
||||
const now = db.prepare("SELECT datetime('now','localtime') as t").get().t;
|
||||
const due = db.prepare(`
|
||||
|
||||
@@ -288,6 +288,28 @@ function publishDiscovery() {
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
|
||||
// ── Dashboard ─────────────────────────────────────────────────────────
|
||||
['sensor', 'dickendock_dashboard_push_erinnerungen', {
|
||||
name: 'Dashboard Push-Erinnerungen offen',
|
||||
unique_id: 'dickendock_dashboard_push_erinnerungen',
|
||||
state_topic: `${BASE_TOPIC}/dashboard/push_erinnerungen`,
|
||||
json_attributes_topic: `${BASE_TOPIC}/dashboard/push_erinnerungen_attributes`,
|
||||
icon: 'mdi:bell-ring-outline',
|
||||
unit_of_measurement: 'Erinnerungen', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
|
||||
// ── Kanban ────────────────────────────────────────────────────────────
|
||||
['sensor', 'dickendock_kanban_karten', {
|
||||
name: 'Kanban Karten offen',
|
||||
unique_id: 'dickendock_kanban_karten',
|
||||
state_topic: `${BASE_TOPIC}/kanban/karten`,
|
||||
json_attributes_topic: `${BASE_TOPIC}/kanban/karten_attributes`,
|
||||
icon: 'mdi:view-column-outline',
|
||||
unit_of_measurement: 'Karten', state_class: 'measurement',
|
||||
availability_topic: AVAILABILITY_TOPIC, device: DEVICE,
|
||||
}],
|
||||
|
||||
// ── System ────────────────────────────────────────────────────────────
|
||||
['sensor', 'dickendock_system_online', {
|
||||
name: 'System Nutzer online',
|
||||
@@ -542,6 +564,43 @@ function publishDruckStatistik() {
|
||||
pub('druck/bestellungen_gesamt', allOrders.length);
|
||||
}
|
||||
|
||||
// Wird jede Minute vom Scheduler aufgerufen — offene (noch nicht versendete,
|
||||
// in der Zukunft liegende) Push-Erinnerungen aus dem Dashboard, systemweit
|
||||
function publishPushErinnerungen() {
|
||||
if (!client?.connected) return;
|
||||
const offen = db.prepare(`
|
||||
SELECT s.message, s.scheduled_at, u.username
|
||||
FROM push_schedules s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.sent = 0 AND s.scheduled_at > datetime('now','localtime')
|
||||
ORDER BY s.scheduled_at ASC
|
||||
`).all();
|
||||
client.publish(`${BASE_TOPIC}/dashboard/push_erinnerungen`, String(offen.length), { retain: true });
|
||||
client.publish(`${BASE_TOPIC}/dashboard/push_erinnerungen_attributes`, JSON.stringify({
|
||||
erinnerungen: offen,
|
||||
}), { retain: true });
|
||||
}
|
||||
|
||||
// Wird jede Minute vom Scheduler aufgerufen — alle Kanban-Karten über alle
|
||||
// Nutzer-Boards hinweg (Kanban ist pro Nutzer, hier systemweit aggregiert)
|
||||
function publishKanban() {
|
||||
if (!client?.connected) return;
|
||||
const karten = db.prepare(`
|
||||
SELECT k.title, k.priority, col.title AS spalte, u.username
|
||||
FROM kanban_cards k
|
||||
JOIN kanban_columns col ON col.id = k.column_id
|
||||
JOIN users u ON u.id = k.user_id
|
||||
ORDER BY u.username ASC, col.position ASC, k.position ASC
|
||||
`).all();
|
||||
const proNutzer = {};
|
||||
for (const k of karten) proNutzer[k.username] = (proNutzer[k.username] || 0) + 1;
|
||||
|
||||
client.publish(`${BASE_TOPIC}/kanban/karten`, String(karten.length), { retain: true });
|
||||
client.publish(`${BASE_TOPIC}/kanban/karten_attributes`, JSON.stringify({
|
||||
karten,
|
||||
pro_nutzer: proNutzer,
|
||||
}), { retain: true });
|
||||
}
|
||||
|
||||
// Wird von index.js gesetzt, um Zirkel-Requires zu vermeiden
|
||||
function setKoepiCheckHandler(fn) { koepiCheckHandler = fn; }
|
||||
function setMediaAckHandler(fn) { mediaAckHandler = fn; }
|
||||
@@ -553,6 +612,8 @@ module.exports = {
|
||||
publishPresence,
|
||||
publishMediaAnfragen,
|
||||
publishDruckStatistik,
|
||||
publishPushErinnerungen,
|
||||
publishKanban,
|
||||
setKoepiCheckHandler,
|
||||
setMediaAckHandler,
|
||||
setMediaAckAllHandler,
|
||||
|
||||
@@ -32,7 +32,7 @@ router.post('/restore', authenticate, requireAdmin, upload.single('database'), (
|
||||
// GET /api/admin/users – alle Benutzer mit Statistiken
|
||||
router.get('/users', authenticate, requireAdmin, (req, res) => {
|
||||
const users = db.prepare(`
|
||||
SELECT u.id, u.username, u.role, u.created_at, u.last_active_at, u.hidden,
|
||||
SELECT u.id, u.username, u.role, u.created_at, u.last_active_at, u.hidden, u.hidden_tools,
|
||||
CASE WHEN p.user_id IS NOT NULL THEN 1 ELSE 0 END as has_pushover
|
||||
FROM users u
|
||||
LEFT JOIN pushover_settings p ON p.user_id = u.id
|
||||
@@ -47,7 +47,9 @@ router.get('/users', authenticate, requireAdmin, (req, res) => {
|
||||
const files = db.prepare('SELECT COUNT(*) c FROM files WHERE user_id=?').get(u.id).c;
|
||||
const noteLen = (db.prepare('SELECT content FROM notes WHERE user_id=?').get(u.id)?.content || '').length;
|
||||
const icals = db.prepare('SELECT COUNT(*) c FROM calendar_feeds WHERE user_id=?').get(u.id).c;
|
||||
return { ...u, archiv, bestellung, snippets_c, todos, links, files, icals, noteLen };
|
||||
let hidden_tools = [];
|
||||
try { hidden_tools = JSON.parse(u.hidden_tools || '[]'); } catch {}
|
||||
return { ...u, hidden_tools, archiv, bestellung, snippets_c, todos, links, files, icals, noteLen };
|
||||
});
|
||||
res.json(stats);
|
||||
});
|
||||
@@ -170,6 +172,18 @@ router.put('/users/:id/hidden', authenticate, requireAdmin, (req, res) => {
|
||||
res.json({ hidden: newHidden });
|
||||
});
|
||||
|
||||
// PUT /api/admin/users/:id/hidden-tools – Sidebar-Sichtbarkeit für einen User setzen
|
||||
// body: { hidden_tools: ['schocken', 'paywallkiller', ...] } (tool-ids aus toolRegistry.js)
|
||||
router.put('/users/:id/hidden-tools', authenticate, requireAdmin, (req, res) => {
|
||||
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id);
|
||||
if (!user) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||
const { hidden_tools } = req.body;
|
||||
if (!Array.isArray(hidden_tools)) return res.status(400).json({ error: 'hidden_tools muss ein Array sein' });
|
||||
const cleaned = [...new Set(hidden_tools.filter(t => typeof t === 'string'))];
|
||||
db.prepare('UPDATE users SET hidden_tools=? WHERE id=?').run(JSON.stringify(cleaned), user.id);
|
||||
res.json({ hidden_tools: cleaned });
|
||||
});
|
||||
|
||||
// Test-Pushover an beliebigen User senden
|
||||
router.post('/users/:id/test-push', authenticate, requireAdmin, async (req, res) => {
|
||||
const user = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id);
|
||||
|
||||
@@ -166,4 +166,14 @@ router.put('/preferences', authenticate, (req, res) => {
|
||||
res.json(merged);
|
||||
});
|
||||
|
||||
// ── GET /api/auth/hidden-tools ────────────────────────────────────────────────
|
||||
// Liefert die vom Admin für diesen Nutzer ausgeblendeten Sidebar-Bereiche.
|
||||
// Bewusst NICHT über /preferences (das ist selbst-editierbar) — Sichtbarkeit
|
||||
// darf nur der Admin ändern.
|
||||
router.get('/hidden-tools', authenticate, (req, res) => {
|
||||
const user = db.prepare('SELECT hidden_tools FROM users WHERE id=?').get(req.user.id);
|
||||
try { res.json(JSON.parse(user?.hidden_tools || '[]')); }
|
||||
catch { res.json([]); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -155,7 +155,7 @@ function Login({ onLogin }) {
|
||||
|
||||
// ── Update Modal ──────────────────────────────────────────────────────────────
|
||||
// ── Desktop Sidebar ───────────────────────────────────────────────────────────
|
||||
function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns=0, whiteboardUnread=0, isAdmin=false }) {
|
||||
function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns=0, whiteboardUnread=0, isAdmin=false, hiddenTools=[] }) {
|
||||
const [appsOpen, setAppsOpen] = useState(true);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState({});
|
||||
@@ -255,7 +255,7 @@ function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns
|
||||
{!collapsed && <span style={{ flex:1, color:'rgba(255,255,255,0.65)' }}>Apps</span>}
|
||||
{!collapsed && <ChevronIcon size={13} color="rgba(255,255,255,0.35)" dir={appsOpen?'down':'right'}/>}
|
||||
</button>
|
||||
{appsOpen && getGroupedTools(isAdmin).map(([group, tools]) => {
|
||||
{appsOpen && getGroupedTools(isAdmin, hiddenTools).map(([group, tools]) => {
|
||||
const isGroupCollapsed = !!collapsedGroups[group];
|
||||
return (
|
||||
<div key={group}>
|
||||
@@ -296,7 +296,7 @@ function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns
|
||||
}
|
||||
|
||||
// ── Mobile Bottom Navigation ──────────────────────────────────────────────────
|
||||
function BottomNav({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns=0, whiteboardUnread=0, isAdmin=false }) {
|
||||
function BottomNav({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns=0, whiteboardUnread=0, isAdmin=false, hiddenTools=[] }) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [appsOpen, setAppsOpen] = useState(false);
|
||||
const [mobileCollapsedGroups, setMobileCollapsedGroups] = useState({});
|
||||
@@ -360,7 +360,7 @@ function BottomNav({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTur
|
||||
{/* Apps Sheet mit Gruppenüberschriften */}
|
||||
<Sheet open={appsOpen} onClose={()=>setAppsOpen(false)}>
|
||||
<div style={{ overflowY:'auto', maxHeight:'60vh', paddingBottom:8 }}>
|
||||
{getGroupedTools(user?.role==='admin').map(([group, tools]) => {
|
||||
{getGroupedTools(user?.role==='admin', hiddenTools).map(([group, tools]) => {
|
||||
const isGrpCollapsed = !!mobileCollapsedGroups[group];
|
||||
return (
|
||||
<div key={group}>
|
||||
@@ -2380,6 +2380,8 @@ function UserManagement({ toast }) {
|
||||
const [resetId, setResetId] = useState(null);
|
||||
const [resetPw, setResetPw] = useState('');
|
||||
const [delId, setDelId] = useState(null);
|
||||
const [visId, setVisId] = useState(null);
|
||||
const [visSelection, setVisSelection] = useState([]);
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
|
||||
const load = () => {
|
||||
@@ -2411,6 +2413,24 @@ function UserManagement({ toast }) {
|
||||
} catch(e) { toast(e.message,'error'); }
|
||||
};
|
||||
|
||||
const openVisibility = (u) => {
|
||||
setVisId(u.id);
|
||||
setVisSelection(u.hidden_tools || []);
|
||||
};
|
||||
|
||||
const toggleVisTool = (toolId) => {
|
||||
setVisSelection(prev => prev.includes(toolId) ? prev.filter(t=>t!==toolId) : [...prev, toolId]);
|
||||
};
|
||||
|
||||
const saveVisibility = async () => {
|
||||
try {
|
||||
await api(`/admin/users/${visId}/hidden-tools`,{method:'PUT',body:{hidden_tools:visSelection}});
|
||||
setUsers(p=>p.map(x=>x.id===visId?{...x,hidden_tools:visSelection}:x));
|
||||
toast('Sichtbarkeit gespeichert');
|
||||
setVisId(null);
|
||||
} catch(e) { toast(e.message,'error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ConfirmDialog/>
|
||||
@@ -2448,6 +2468,51 @@ function UserManagement({ toast }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sidebar-Sichtbarkeit Modal */}
|
||||
{visId && (() => {
|
||||
const grouped = {};
|
||||
for (const t of TOOLS) {
|
||||
const g = t.group || 'Allgemein';
|
||||
if (!grouped[g]) grouped[g] = [];
|
||||
grouped[g].push(t);
|
||||
}
|
||||
const target = users.find(u=>u.id===visId);
|
||||
return (
|
||||
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:5000,
|
||||
display:'flex',alignItems:'center',justifyContent:'center',padding:20}}>
|
||||
<div style={{...S.card,width:'100%',maxWidth:420,maxHeight:'80vh',overflowY:'auto'}}>
|
||||
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:14,fontWeight:700,marginBottom:4}}>
|
||||
Sichtbare Bereiche{target ? ` — ${target.username}` : ''}
|
||||
</div>
|
||||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:14}}>
|
||||
Angehakt = ausgeblendet für diesen Nutzer
|
||||
</div>
|
||||
{Object.entries(grouped).map(([group, tools]) => (
|
||||
<div key={group} style={{marginBottom:14}}>
|
||||
<div style={{...S.head,marginBottom:6}}>{group}</div>
|
||||
{tools.map(t => (
|
||||
<label key={t.id} style={{
|
||||
display:'flex',alignItems:'center',gap:8,padding:'6px 2px',cursor:'pointer',
|
||||
}}>
|
||||
<input type="checkbox" checked={visSelection.includes(t.id)}
|
||||
onChange={()=>toggleVisTool(t.id)}
|
||||
style={{width:15,height:15,accentColor:'#ff6b9d',flexShrink:0}}/>
|
||||
<span style={{color:'rgba(255,255,255,0.75)',fontFamily:'monospace',fontSize:12}}>
|
||||
{t.label}{t.adminOnly ? ' (nur Admin)' : ''}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div style={{display:'flex',gap:8,marginTop:8}}>
|
||||
<button onClick={saveVisibility} style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'10px 0'}}>✓ Speichern</button>
|
||||
<button onClick={()=>setVisId(null)} style={{...S.btn('#ff6b9d'),flex:1,textAlign:'center',padding:'10px 0'}}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Benutzerliste */}
|
||||
<Sec title="BENUTZER">
|
||||
{loading && <div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>Lädt…</div>}
|
||||
@@ -2508,6 +2573,11 @@ function UserManagement({ toast }) {
|
||||
)}
|
||||
<button onClick={()=>{setResetId(u.id);setResetPw('');}}
|
||||
style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 8px'}}>🔑</button>
|
||||
<button onClick={()=>openVisibility(u)}
|
||||
title="Sichtbare Bereiche"
|
||||
style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'4px 8px'}}>
|
||||
🧩{u.hidden_tools?.length ? ` ${u.hidden_tools.length}` : ''}
|
||||
</button>
|
||||
<button
|
||||
onClick={async ()=>{
|
||||
if (u.role==='admin') return;
|
||||
@@ -3702,6 +3772,7 @@ export default function App() {
|
||||
return window.matchMedia('(display-mode: standalone)').matches && !sessionStorage.getItem('splash_shown');
|
||||
});
|
||||
const [user,setUser]=useState(null);
|
||||
const [hiddenTools,setHiddenTools]=useState([]);
|
||||
const [active,setActiveRaw]=useState('dashboard');
|
||||
const [devToolsNav, setDevToolsNav] = useState(null);
|
||||
const pollUnackedFavs = () => api('/tools/media/favorites/unacked').then(d=>setUnackedFavs({count:d.count||0,isAdmin:d.isAdmin})).catch(()=>{});
|
||||
@@ -3741,6 +3812,20 @@ export default function App() {
|
||||
if(!user)return;
|
||||
},[user]);
|
||||
|
||||
// Vom Admin ausgeblendete Sidebar-Bereiche laden (einmal pro Login)
|
||||
useEffect(()=>{
|
||||
if(!user)return;
|
||||
api('/auth/hidden-tools').then(setHiddenTools).catch(()=>{});
|
||||
},[user]);
|
||||
|
||||
// Falls der aktuell offene Bereich inzwischen ausgeblendet wurde (z.B. Admin
|
||||
// hat während der laufenden Session die Sichtbarkeit geändert): zurück zum Dashboard
|
||||
useEffect(()=>{
|
||||
if (active !== 'dashboard' && active !== 'admin' && hiddenTools.includes(active)) {
|
||||
setActiveRaw('dashboard');
|
||||
}
|
||||
},[active, hiddenTools]);
|
||||
|
||||
// Schlüssel sofort nach Login initialisieren und Public Key registrieren
|
||||
useEffect(()=>{
|
||||
if(!user)return;
|
||||
@@ -3806,7 +3891,7 @@ export default function App() {
|
||||
if(!authChecked) return <div style={{background:'#0d0d0f',minHeight:'100vh'}}/>;
|
||||
if(!user) return <Login onLogin={u=>setUser(u)}/>;
|
||||
|
||||
const activeTool = TOOLS.find(t=>t.id===active);
|
||||
const activeTool = hiddenTools.includes(active) ? null : TOOLS.find(t=>t.id===active);
|
||||
|
||||
// Mobile padding bottom für fixed nav
|
||||
const isNachrichten = active === 'nachrichten';
|
||||
@@ -3832,9 +3917,9 @@ export default function App() {
|
||||
<div style={{display:'flex',flexDirection:'row',height:'100dvh',background:'#111114'}}>
|
||||
{!mobile && (
|
||||
<Sidebar active={active} setActive={setActive} user={user}
|
||||
onLogout={()=>{localStorage.removeItem('sk_token');setUser(null);}}
|
||||
onLogout={()=>{localStorage.removeItem('sk_token');setUser(null);setHiddenTools([]);}}
|
||||
|
||||
unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread} isAdmin={user?.role==='admin'}/>
|
||||
unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread} isAdmin={user?.role==='admin'} hiddenTools={hiddenTools}/>
|
||||
)}
|
||||
<main ref={mainRef} style={mainStyle}>
|
||||
{active==='dashboard' && <Dashboard key={dashboardKey} toast={toast} mobile={mobile} setActive={setActive} setDevToolsNav={setDevToolsNav} unreadMsgs={unreadMsgs} unreadBoard={unreadBoard} unreadPromoted={unreadPromoted} onBoardRead={()=>{ setUnreadBoard(0); setUnreadPromoted(0); }} unreadChangelog={unreadChangelog} onChangelogRead={()=>setUnreadChangelog(0)} unackedFavs={unackedFavs} onClearUnackedFavs={()=>setUnackedFavs({count:0,isAdmin:false})} user={user}/>}
|
||||
@@ -3844,9 +3929,9 @@ export default function App() {
|
||||
</div>
|
||||
{mobile && (
|
||||
<BottomNav active={active} setActive={setActive} user={user}
|
||||
onLogout={()=>{localStorage.removeItem('sk_token');setUser(null);}}
|
||||
onLogout={()=>{localStorage.removeItem('sk_token');setUser(null);setHiddenTools([]);}}
|
||||
|
||||
unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread} isAdmin={user?.role==='admin'}/>
|
||||
unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread} isAdmin={user?.role==='admin'} hiddenTools={hiddenTools}/>
|
||||
)}
|
||||
<ScrollToTop scrollRef={mainRef}/>
|
||||
<Toast msg={toastMsg.msg} type={toastMsg.type}/>
|
||||
|
||||
Reference in New Issue
Block a user