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:
@@ -1266,9 +1266,27 @@ async function initializeDatabase() {
|
|||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
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
|
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(`
|
await exec(`
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
token TEXT PRIMARY KEY,
|
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 != ''",
|
"SELECT value FROM app_state WHERE user_id = ? AND key = 'cfg:OLLAMA_API_KEY' AND value != ''",
|
||||||
[ziel.id]
|
[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', {
|
res.render('jobsuche', {
|
||||||
profil,
|
profil,
|
||||||
@@ -4086,6 +4114,8 @@ initializeDatabase().then(async () => {
|
|||||||
hatLebenslauf: Boolean(lebenslauf),
|
hatLebenslauf: Boolean(lebenslauf),
|
||||||
hatUnterlagen: Boolean(unterlagen),
|
hatUnterlagen: Boolean(unterlagen),
|
||||||
hatOllamaKey: Boolean(ollamaKey),
|
hatOllamaKey: Boolean(ollamaKey),
|
||||||
|
darfSuchlaufStarten,
|
||||||
|
darfZeitplanAendern,
|
||||||
fehler: req.query.fehler ? String(req.query.fehler) : null,
|
fehler: req.query.fehler ? String(req.query.fehler) : null,
|
||||||
hinweis: req.query.hinweis ? String(req.query.hinweis) : null,
|
hinweis: req.query.hinweis ? String(req.query.hinweis) : null,
|
||||||
hideSettings: false,
|
hideSettings: false,
|
||||||
@@ -4103,6 +4133,21 @@ initializeDatabase().then(async () => {
|
|||||||
const suffix = ziel.fremd ? `?user=${ziel.id}` : '';
|
const suffix = ziel.fremd ? `?user=${ziel.id}` : '';
|
||||||
|
|
||||||
const profil = suchprofil.fromForm(req.body, sanitizeInput);
|
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);
|
const fehler = suchprofil.validate(profil);
|
||||||
if (fehler.length) {
|
if (fehler.length) {
|
||||||
return res.redirect(`/jobsuche${suffix}${suffix ? '&' : '?'}fehler=${encodeURIComponent(fehler.join(' '))}`);
|
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 suffix = ziel.fremd ? `?user=${ziel.id}` : '';
|
||||||
const sep = suffix ? '&' : '?';
|
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 profil = await loadSuchprofil(ziel.id);
|
||||||
const fehler = suchprofil.validate(profil);
|
const fehler = suchprofil.validate(profil);
|
||||||
if (fehler.length) {
|
if (fehler.length) {
|
||||||
@@ -4288,7 +4343,7 @@ initializeDatabase().then(async () => {
|
|||||||
app.get('/admin/users/:id', requireAdmin, async (req, res) => {
|
app.get('/admin/users/:id', requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = Number(req.params.id);
|
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.');
|
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 anzahl = await dbGet('SELECT COUNT(*) AS c FROM bewerbungen WHERE user_id = ?', [id]);
|
||||||
const bewerbungen = await dbAll(
|
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 --------------------------
|
// --- Impersonation: admin "logs in as" a user --------------------------
|
||||||
// Best practice (Django/Flask-impersonate style): the admin's own session
|
// 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
|
// keeps its cookie token, but its user_id switches to the target and the
|
||||||
|
|||||||
@@ -66,6 +66,42 @@
|
|||||||
|
|
||||||
<hr class="border-gray-100 dark:border-gray-700">
|
<hr class="border-gray-100 dark:border-gray-700">
|
||||||
|
|
||||||
|
<!-- Suchläufe starten -->
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">Suchläufe selber starten</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Manueller „Jetzt suchen"-Knopf auf der Jobsuche. Der automatische Zeitplan läuft davon unberührt.</p>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/admin/users/<%= user.id %>/toggle-suchlauf" class="inline">
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors
|
||||||
|
<%= user.darf_suchlauf_starten ? 'bg-green-600 hover:bg-green-700 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600' %>">
|
||||||
|
<span class="inline-block w-2 h-2 rounded-full <%= user.darf_suchlauf_starten ? 'bg-white' : 'bg-gray-400' %>"></span>
|
||||||
|
<%= user.darf_suchlauf_starten ? 'Erlaubt' : 'Gesperrt' %>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-gray-100 dark:border-gray-700">
|
||||||
|
|
||||||
|
<!-- Zeitplan ändern -->
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">Zeitplan ändern dürfen</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Wochentage, Uhrzeit und „aktiv". Standard: Mo–Fr 17:00, verwaltet vom Administrator.</p>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/admin/users/<%= user.id %>/toggle-zeitplan" class="inline">
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors
|
||||||
|
<%= user.darf_zeitplan_aendern ? 'bg-green-600 hover:bg-green-700 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600' %>">
|
||||||
|
<span class="inline-block w-2 h-2 rounded-full <%= user.darf_zeitplan_aendern ? 'bg-white' : 'bg-gray-400' %>"></span>
|
||||||
|
<%= user.darf_zeitplan_aendern ? 'Erlaubt' : 'Gesperrt' %>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-gray-100 dark:border-gray-700">
|
||||||
|
|
||||||
<!-- Passwort zurücksetzen -->
|
<!-- Passwort zurücksetzen -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-2">
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-2">
|
||||||
|
|||||||
+18
-4
@@ -64,10 +64,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<label class="inline-flex items-center gap-2 shrink-0">
|
<label class="inline-flex items-center gap-2 shrink-0">
|
||||||
<input type="checkbox" name="aktiv" value="1" <%= profil.aktiv ? 'checked' : '' %>
|
<input type="checkbox" name="aktiv" value="1" <%= profil.aktiv ? 'checked' : '' %>
|
||||||
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
|
<%= !darfZeitplanAendern ? 'disabled' : '' %>
|
||||||
|
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 disabled:opacity-60">
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">aktiv</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">aktiv</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<% if (!darfZeitplanAendern) { %>
|
||||||
|
<p class="text-xs text-amber-700 dark:text-amber-300 mt-3">
|
||||||
|
Der Zeitplan (aktiv, Wochentage, Uhrzeit) wird vom Administrator verwaltet.
|
||||||
|
</p>
|
||||||
|
<% } %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||||
@@ -162,10 +168,11 @@
|
|||||||
|
|
||||||
<div class="flex flex-wrap gap-2 mb-5">
|
<div class="flex flex-wrap gap-2 mb-5">
|
||||||
<% wochentage.forEach(function (t) { %>
|
<% wochentage.forEach(function (t) { %>
|
||||||
<label class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-gray-300 dark:border-gray-600 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/40">
|
<label class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-gray-300 dark:border-gray-600 text-sm <%= darfZeitplanAendern ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/40' : 'opacity-70 cursor-not-allowed' %>">
|
||||||
<input type="checkbox" name="tage" value="<%= t.nr %>"
|
<input type="checkbox" name="tage" value="<%= t.nr %>"
|
||||||
<%= profil.zeitplan_tage.indexOf(t.nr) !== -1 ? 'checked' : '' %>
|
<%= profil.zeitplan_tage.indexOf(t.nr) !== -1 ? 'checked' : '' %>
|
||||||
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
|
<%= !darfZeitplanAendern ? 'disabled' : '' %>
|
||||||
|
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 disabled:opacity-60">
|
||||||
<span class="text-gray-700 dark:text-gray-300"><%= t.kurz %></span>
|
<span class="text-gray-700 dark:text-gray-300"><%= t.kurz %></span>
|
||||||
</label>
|
</label>
|
||||||
<% }); %>
|
<% }); %>
|
||||||
@@ -173,7 +180,8 @@
|
|||||||
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="zeitplan_zeit">Uhrzeit</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="zeitplan_zeit">Uhrzeit</label>
|
||||||
<input type="time" id="zeitplan_zeit" name="zeitplan_zeit" value="<%= profil.zeitplan_zeit %>"
|
<input type="time" id="zeitplan_zeit" name="zeitplan_zeit" value="<%= profil.zeitplan_zeit %>"
|
||||||
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm">
|
<%= !darfZeitplanAendern ? 'disabled' : '' %>
|
||||||
|
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm disabled:opacity-60">
|
||||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
Der Lauf startet innerhalb weniger Minuten nach dieser Uhrzeit.
|
Der Lauf startet innerhalb weniger Minuten nach dieser Uhrzeit.
|
||||||
</p>
|
</p>
|
||||||
@@ -203,6 +211,7 @@
|
|||||||
<a href="/jobangebote" class="text-blue-600 dark:text-blue-400 hover:underline">Jobangebote</a> an.
|
<a href="/jobangebote" class="text-blue-600 dark:text-blue-400 hover:underline">Jobangebote</a> an.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<% if (darfSuchlaufStarten) { %>
|
||||||
<form action="/jobsuche/start" method="POST" class="shrink-0">
|
<form action="/jobsuche/start" method="POST" class="shrink-0">
|
||||||
<% if (fremd) { %><input type="hidden" name="ziel_user" value="<%= zielUser.id %>"><% } %>
|
<% if (fremd) { %><input type="hidden" name="ziel_user" value="<%= zielUser.id %>"><% } %>
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
@@ -213,6 +222,11 @@
|
|||||||
Jetzt suchen
|
Jetzt suchen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<% } else if (!fremd) { %>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 shrink-0 text-right max-w-[12rem]">
|
||||||
|
Manuelle Suchläufe werden vom Administrator verwaltet.
|
||||||
|
</p>
|
||||||
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="laeufe">
|
<div id="laeufe">
|
||||||
|
|||||||
Reference in New Issue
Block a user