Admin: pro-Nutzer-Rechte für Suchlauf-Start und Zeitplan

- Neue users-Spalten darf_suchlauf_starten / darf_zeitplan_aendern
  (Default 0 = deaktiviert); idempotente ALTER-Migration für Bestands-DBs.
- Admin-Detailseite: zwei Toggles (Erlaubt/Gesperrt) je Nutzer.
- /jobsuche: ohne Recht ist „Jetzt suchen" ausgeblendet und der Zeitplan
  (aktiv/Tage/Uhrzeit) read-only; Speichern und manueller Start sind
  serverseitig abgesichert. Admin (fremd) ist nie beschränkt.
- Default-Zeitplan bleibt Mo–Fr 17:00 (suchprofil-Defaults).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-20 08:47:48 +02:00
co-authored by Claude
parent fa07dbf90a
commit 1c913b2b28
3 changed files with 145 additions and 5 deletions
+91 -1
View File
@@ -1266,9 +1266,27 @@ async function initializeDatabase() {
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
-- Darf der Nutzer selber Suchläufe starten („Jetzt suchen") und darf er
-- den Zeitplan (Wochentage/Uhrzeit/aktiv) ändern? Beides ist standardmäßig
-- AUS — der Admin aktiviert es pro Nutzer. Der automatische Zeitplan selbst
-- läuft weiter, sobald aktiviert (nur das Starten/Ändern ist beschränkt).
darf_suchlauf_starten INTEGER NOT NULL DEFAULT 0,
darf_zeitplan_aendern INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Bestehende Installationen: die beiden Spalten kamen nach dem CREATE TABLE
// dazu. ALTER ist idempotent via PRAGMA table_info (Default 0 = deaktiviert,
// auch für alle existierenden Nutzer).
{
const userSpalten = await dbAll('PRAGMA table_info(users)');
if (!userSpalten.some((s) => s.name === 'darf_suchlauf_starten')) {
await exec('ALTER TABLE users ADD COLUMN darf_suchlauf_starten INTEGER NOT NULL DEFAULT 0');
}
if (!userSpalten.some((s) => s.name === 'darf_zeitplan_aendern')) {
await exec('ALTER TABLE users ADD COLUMN darf_zeitplan_aendern INTEGER NOT NULL DEFAULT 0');
}
}
await exec(`
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
@@ -4074,6 +4092,16 @@ initializeDatabase().then(async () => {
"SELECT value FROM app_state WHERE user_id = ? AND key = 'cfg:OLLAMA_API_KEY' AND value != ''",
[ziel.id]
);
// Pro-Nutzer-Rechte: darf der Zielbenutzer Suchläufe manuell starten und
// darf er den Zeitplan ändern? Ein Admin, der ein fremdes Profil verwaltet
// (fremd), darf beides immer — die Rechte schränken nur den eigentlichen
// Nutzer auf seiner eigenen /jobsuche ein.
const rechte = await dbGet(
'SELECT darf_suchlauf_starten, darf_zeitplan_aendern FROM users WHERE id = ?',
[ziel.id]
);
const darfSuchlaufStarten = ziel.fremd || Boolean(rechte && rechte.darf_suchlauf_starten);
const darfZeitplanAendern = ziel.fremd || Boolean(rechte && rechte.darf_zeitplan_aendern);
res.render('jobsuche', {
profil,
@@ -4086,6 +4114,8 @@ initializeDatabase().then(async () => {
hatLebenslauf: Boolean(lebenslauf),
hatUnterlagen: Boolean(unterlagen),
hatOllamaKey: Boolean(ollamaKey),
darfSuchlaufStarten,
darfZeitplanAendern,
fehler: req.query.fehler ? String(req.query.fehler) : null,
hinweis: req.query.hinweis ? String(req.query.hinweis) : null,
hideSettings: false,
@@ -4103,6 +4133,21 @@ initializeDatabase().then(async () => {
const suffix = ziel.fremd ? `?user=${ziel.id}` : '';
const profil = suchprofil.fromForm(req.body, sanitizeInput);
// Ohne das Recht „Zeitplan ändern" (und nicht als Admin unterwegs) darf
// der Nutzer die Wochentage/Uhrzeit/den aktiv-Schalter nicht anfassen —
// der adminstrulierte Zeitplan bleibt erhalten. Nur die Suchbegriffe /
// der Modus sind dann noch editierbar.
if (!ziel.fremd) {
const rechte = await dbGet('SELECT darf_zeitplan_aendern FROM users WHERE id = ?', [ziel.id]);
if (!rechte || !rechte.darf_zeitplan_aendern) {
const aktuell = await loadSuchprofil(ziel.id);
profil.aktiv = aktuell.aktiv;
profil.zeitplan_tage = aktuell.zeitplan_tage;
profil.zeitplan_zeit = aktuell.zeitplan_zeit;
}
}
const fehler = suchprofil.validate(profil);
if (fehler.length) {
return res.redirect(`/jobsuche${suffix}${suffix ? '&' : '?'}fehler=${encodeURIComponent(fehler.join(' '))}`);
@@ -4164,6 +4209,16 @@ initializeDatabase().then(async () => {
const suffix = ziel.fremd ? `?user=${ziel.id}` : '';
const sep = suffix ? '&' : '?';
// Manuelle Suchläufe sind ein eigenes Recht. Ein Admin, der ein fremdes
// Profil verwaltet, darf immer starten — der eigentliche Nutzer nur, wenn
// der Admin es ihm freigegeben hat.
if (!ziel.fremd) {
const rechte = await dbGet('SELECT darf_suchlauf_starten FROM users WHERE id = ?', [ziel.id]);
if (!rechte || !rechte.darf_suchlauf_starten) {
return res.status(403).send('Suchläufe dürfen nur von einem Administrator gestartet werden.');
}
}
const profil = await loadSuchprofil(ziel.id);
const fehler = suchprofil.validate(profil);
if (fehler.length) {
@@ -4288,7 +4343,7 @@ initializeDatabase().then(async () => {
app.get('/admin/users/:id', requireAdmin, async (req, res) => {
try {
const id = Number(req.params.id);
const user = await dbGet('SELECT id, username, is_admin, created_at FROM users WHERE id = ?', [id]);
const user = await dbGet('SELECT id, username, is_admin, darf_suchlauf_starten, darf_zeitplan_aendern, created_at FROM users WHERE id = ?', [id]);
if (!user) return res.status(404).send('Benutzer nicht gefunden.');
const anzahl = await dbGet('SELECT COUNT(*) AS c FROM bewerbungen WHERE user_id = ?', [id]);
const bewerbungen = await dbAll(
@@ -4331,6 +4386,41 @@ initializeDatabase().then(async () => {
}
});
// Darf der Nutzer Suchläufe selber starten („Jetzt suchen")? Standardmäßig
// aus — der Admin gibt es pro Nutzer frei. Der automatische Zeitplan läuft
// davon unberührt weiter, sobald er aktiviert ist.
app.post('/admin/users/:id/toggle-suchlauf', requireAdmin, async (req, res) => {
try {
const id = Number(req.params.id);
const target = await dbGet('SELECT id, darf_suchlauf_starten FROM users WHERE id = ?', [id]);
if (!target) return res.status(404).send('Benutzer nicht gefunden.');
const neu = target.darf_suchlauf_starten ? 0 : 1;
await dbRun('UPDATE users SET darf_suchlauf_starten = ? WHERE id = ?', [neu, id]);
await auditLog(req.user.id, id, `suchlauf_starten → ${neu ? 'an' : 'aus'}`);
res.redirect('/admin/users/' + id);
} catch (error) {
console.error('Admin toggle-suchlauf error:', error);
res.status(500).send('Serverfehler');
}
});
// Darf der Nutzer den Zeitplan (Wochentage/Uhrzeit/aktiv) ändern?
// Standardmäßig aus — nur der Admin stellt den Zeitplan ein.
app.post('/admin/users/:id/toggle-zeitplan', requireAdmin, async (req, res) => {
try {
const id = Number(req.params.id);
const target = await dbGet('SELECT id, darf_zeitplan_aendern FROM users WHERE id = ?', [id]);
if (!target) return res.status(404).send('Benutzer nicht gefunden.');
const neu = target.darf_zeitplan_aendern ? 0 : 1;
await dbRun('UPDATE users SET darf_zeitplan_aendern = ? WHERE id = ?', [neu, id]);
await auditLog(req.user.id, id, `zeitplan_aendern → ${neu ? 'an' : 'aus'}`);
res.redirect('/admin/users/' + id);
} catch (error) {
console.error('Admin toggle-zeitplan error:', error);
res.status(500).send('Serverfehler');
}
});
// --- Impersonation: admin "logs in as" a user --------------------------
// Best practice (Django/Flask-impersonate style): the admin's own session
// keeps its cookie token, but its user_id switches to the target and the