Multi-User-Plattform: jeder Benutzer hat eigene, isolierte Daten
- Auth via Session-Cookie + Login-Seite (scrypt, lib/password.js, sessions-Tabelle) - AsyncLocalStorage (lib/context.js) propagiert aktuellen Benutzer durch alle Libs - user_id auf allen Datentabellen (FK->users ON DELETE CASCADE), per-user PK/UNIQUE (app_state, settings, prompts, design, jobangebote) und per-user Dateispeicher (data/<dir>/<userId>/) - Alle Queries in server.js + lib/api.js nach user_id scope-iert - Pro-Benutzer-Konfiguration (Ollama/Mail/CalDAV/API-Token) in app_state, Live gelesen via config.get(); Hintergrund-Loops (IMAP/CalDAV) iterieren alle Benutzer - REST-API /api/v1: X-API-Key loest den Token zu einem Benutzer auf, Anfragen operieren nur auf dessen Daten - Admin-Panel /admin: Benutzer anlegen, Passwort zuruecksetzen, loeschen (mit Daten) - Idempotente Migration (lib/migrate-multiuser.js + scripts/migrate-to-multiuser.js): bestehende Daten werden dem Benutzer admin:admin zugeordnet Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+142
-111
@@ -1,10 +1,11 @@
|
||||
// Third-party REST API (v1) for the Bewerbungs-Tracker.
|
||||
//
|
||||
// Mounted under /api/v1 in server.js. All endpoints except /health require an
|
||||
// API key (API_TOKEN, editable via /einstellungen) sent in the X-API-Key
|
||||
// header. Reuses the server's existing DB helpers, sanitizer, duplicate guard,
|
||||
// generation runner and attachment directories so behaviour stays consistent
|
||||
// with the web UI.
|
||||
// API key sent in the X-API-Key header. The key is per-user: it is the
|
||||
// API_TOKEN the user saved on /einstellungen. The auth step resolves the key to
|
||||
// the owning user, then runs every request inside that user's context
|
||||
// (lib/context.js) so all queries + file paths are scoped to that user — the
|
||||
// third-party software only ever sees and creates the user's own data.
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
@@ -12,6 +13,9 @@ const fs = require('fs');
|
||||
const blacklist = require('./blacklist');
|
||||
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./labels');
|
||||
const { normalizeDokumente } = require('./documents');
|
||||
const { userContext, currentUserId } = require('./context');
|
||||
|
||||
const CONFIG_PREFIX = 'cfg:';
|
||||
|
||||
// Replace the stored labels JSON string with a real array on outgoing rows.
|
||||
function withLabels(row) {
|
||||
@@ -37,35 +41,53 @@ function createExternalApi(deps) {
|
||||
runGeneration,
|
||||
anhaengeDir,
|
||||
emailAnhaengeDir,
|
||||
apiToken,
|
||||
userStorageDir,
|
||||
} = deps;
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// `apiToken` may be a string (static) or a function () => string (dynamic,
|
||||
// read from the DB on each request so an edit on /einstellungen takes effect
|
||||
// without a restart).
|
||||
const resolveToken = () => (typeof apiToken === 'function' ? apiToken() : apiToken);
|
||||
// The current user's id, resolved from the per-request context set below.
|
||||
const uid = () => currentUserId();
|
||||
|
||||
// --- API key auth --------------------------------------------------
|
||||
// /health is public so monitoring tools can probe availability; everything
|
||||
// else returns 401 when the header is missing/wrong or the token isn't set.
|
||||
router.use((req, res, next) => {
|
||||
// /health is public so monitoring tools can probe availability. Every other
|
||||
// endpoint resolves the X-API-Key to the user who owns that token (stored in
|
||||
// app_state as cfg:API_TOKEN), then wraps the rest of the request in that
|
||||
// user's context so all queries/files are scoped to them. A missing or
|
||||
// unknown key yields 401.
|
||||
router.use(async (req, res, next) => {
|
||||
if (req.path === '/health') return next();
|
||||
const token = resolveToken();
|
||||
if (!token) {
|
||||
return res.status(503).json({ error: 'API-Token nicht konfiguriert (in den Einstellungen setzen).' });
|
||||
}
|
||||
const provided = req.get('X-API-Key');
|
||||
if (!provided || provided !== token) {
|
||||
if (!provided) {
|
||||
return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
|
||||
}
|
||||
next();
|
||||
try {
|
||||
// Resolve the token to its owning user (the user whose cfg:API_TOKEN
|
||||
// matches). Two users could in principle share a value — we take the
|
||||
// first match, which is fine since the data the caller then sees is that
|
||||
// one user's only.
|
||||
const user = await dbGet(
|
||||
`SELECT u.id, u.username, u.is_admin
|
||||
FROM app_state a JOIN users u ON u.id = a.user_id
|
||||
WHERE a.key = ? AND a.value = ? LIMIT 1`,
|
||||
[CONFIG_PREFIX + 'API_TOKEN', provided]
|
||||
);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
|
||||
}
|
||||
req.user = user;
|
||||
// Run the remainder of the request inside this user's context so
|
||||
// currentUserId() / config.get() / the scoped helpers all resolve here.
|
||||
userContext.run(user, next);
|
||||
} catch (err) {
|
||||
console.error('API auth error:', err);
|
||||
res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- helpers -------------------------------------------------------
|
||||
async function getApplication(id) {
|
||||
return dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
|
||||
return dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
|
||||
}
|
||||
|
||||
// --- System --------------------------------------------------------
|
||||
@@ -80,8 +102,8 @@ function createExternalApi(deps) {
|
||||
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500);
|
||||
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
||||
|
||||
const where = [];
|
||||
const params = [];
|
||||
const where = ['user_id = ?'];
|
||||
const params = [uid()];
|
||||
if (label && LABEL_OPTIONS.includes(label)) {
|
||||
// labels is a JSON array string; match the quoted label token.
|
||||
where.push('labels LIKE ?');
|
||||
@@ -109,7 +131,7 @@ function createExternalApi(deps) {
|
||||
params.push(term, term);
|
||||
}
|
||||
|
||||
const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||
const clause = `WHERE ${where.join(' AND ')}`;
|
||||
const applications = await dbAll(
|
||||
`SELECT * FROM bewerbungen ${clause} ORDER BY datum DESC, created_at DESC LIMIT ? OFFSET ?`,
|
||||
[...params, limit, offset]
|
||||
@@ -146,10 +168,11 @@ function createExternalApi(deps) {
|
||||
|
||||
const result = await dbRun(
|
||||
`INSERT INTO bewerbungen
|
||||
(datum, firma, stelle, art, status, notizen, interne_notizen, ort,
|
||||
(user_id, datum, firma, stelle, art, status, notizen, interne_notizen, ort,
|
||||
stellenbeschreibung, quelle_url, llm_notizen, labels, generierung_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`,
|
||||
[
|
||||
uid(),
|
||||
datum,
|
||||
sanitizeInput(firma),
|
||||
sanitizeInput(stelle),
|
||||
@@ -167,12 +190,12 @@ function createExternalApi(deps) {
|
||||
|
||||
if (b.status && String(b.status).trim()) {
|
||||
await dbRun(
|
||||
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
|
||||
[result.lastID, datum, sanitizeInput(b.status), sanitizeInput(b.kommentar || '')]
|
||||
'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)',
|
||||
[uid(), result.lastID, datum, sanitizeInput(b.status), sanitizeInput(b.kommentar || '')]
|
||||
);
|
||||
}
|
||||
|
||||
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [result.lastID]);
|
||||
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [result.lastID, uid()]);
|
||||
res.json({ success: true, application: withLabels(application) });
|
||||
} catch (error) {
|
||||
console.error('API create application error:', error);
|
||||
@@ -208,7 +231,7 @@ function createExternalApi(deps) {
|
||||
datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?,
|
||||
interne_notizen = ?, ort = ?, stellenbeschreibung = ?, quelle_url = ?,
|
||||
llm_notizen = ?, labels = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
[
|
||||
b.datum,
|
||||
sanitizeInput(b.firma),
|
||||
@@ -225,10 +248,11 @@ function createExternalApi(deps) {
|
||||
? serializeLabels(b.labels)
|
||||
: (existing.labels || '[]'),
|
||||
id,
|
||||
uid(),
|
||||
]
|
||||
);
|
||||
|
||||
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
|
||||
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
|
||||
res.json({ success: true, application: withLabels(application) });
|
||||
} catch (error) {
|
||||
console.error('API update application error:', error);
|
||||
@@ -242,8 +266,8 @@ function createExternalApi(deps) {
|
||||
const existing = await getApplication(id);
|
||||
if (!existing) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||
|
||||
await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ?', [id]);
|
||||
await dbRun('DELETE FROM bewerbungen WHERE id = ?', [id]);
|
||||
await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
|
||||
await dbRun('DELETE FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('API delete application error:', error);
|
||||
@@ -257,8 +281,8 @@ function createExternalApi(deps) {
|
||||
const { id } = req.params;
|
||||
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||
const verlauf = await dbAll(
|
||||
'SELECT * FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) ASC, id ASC',
|
||||
[id]
|
||||
'SELECT * FROM status_verlauf WHERE bewerbung_id = ? AND user_id = ? ORDER BY date(datum) ASC, id ASC',
|
||||
[id, uid()]
|
||||
);
|
||||
res.json(verlauf);
|
||||
} catch (error) {
|
||||
@@ -279,12 +303,12 @@ function createExternalApi(deps) {
|
||||
const day = datum || new Date().toISOString().split('T')[0];
|
||||
|
||||
const result = await dbRun(
|
||||
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
|
||||
[id, day, status, sanitizeInput(kommentar || '')]
|
||||
'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)',
|
||||
[uid(), id, day, status, sanitizeInput(kommentar || '')]
|
||||
);
|
||||
await syncCurrentStatus(id);
|
||||
|
||||
const entry = await dbGet('SELECT * FROM status_verlauf WHERE id = ?', [result.lastID]);
|
||||
const entry = await dbGet('SELECT * FROM status_verlauf WHERE id = ? AND user_id = ?', [result.lastID, uid()]);
|
||||
res.json(entry);
|
||||
} catch (error) {
|
||||
console.error('API add timeline error:', error);
|
||||
@@ -296,12 +320,12 @@ function createExternalApi(deps) {
|
||||
try {
|
||||
const { id, eintragId } = req.params;
|
||||
const entry = await dbGet(
|
||||
'SELECT id FROM status_verlauf WHERE id = ? AND bewerbung_id = ?',
|
||||
[eintragId, id]
|
||||
'SELECT id FROM status_verlauf WHERE id = ? AND bewerbung_id = ? AND user_id = ?',
|
||||
[eintragId, id, uid()]
|
||||
);
|
||||
if (!entry) return res.status(404).json({ error: 'Verlaufseintrag nicht gefunden' });
|
||||
|
||||
await dbRun('DELETE FROM status_verlauf WHERE id = ?', [eintragId]);
|
||||
await dbRun('DELETE FROM status_verlauf WHERE id = ? AND user_id = ?', [eintragId, uid()]);
|
||||
await syncCurrentStatus(id);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -316,8 +340,8 @@ function createExternalApi(deps) {
|
||||
const { id } = req.params;
|
||||
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||
const anhaenge = await dbAll(
|
||||
'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC',
|
||||
[id]
|
||||
'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC',
|
||||
[id, uid()]
|
||||
);
|
||||
res.json(anhaenge);
|
||||
} catch (error) {
|
||||
@@ -330,12 +354,12 @@ function createExternalApi(deps) {
|
||||
try {
|
||||
const { id, attachmentId } = req.params;
|
||||
const anhang = await dbGet(
|
||||
'SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ?',
|
||||
[attachmentId, id]
|
||||
'SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?',
|
||||
[attachmentId, id, uid()]
|
||||
);
|
||||
if (!anhang) return res.status(404).json({ error: 'Anhang nicht gefunden' });
|
||||
|
||||
const file = path.join(anhaengeDir, anhang.pfad);
|
||||
const file = path.join(userStorageDir(anhaengeDir), anhang.pfad);
|
||||
if (!fs.existsSync(file)) return res.status(404).json({ error: 'Datei nicht auf Festplatte vorhanden' });
|
||||
|
||||
res.download(file, anhang.dateiname || anhang.name || path.basename(anhang.pfad));
|
||||
@@ -352,14 +376,14 @@ function createExternalApi(deps) {
|
||||
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||
|
||||
const emails = await dbAll(
|
||||
'SELECT * FROM emails WHERE bewerbung_id = ? ORDER BY datetime(email_date) ASC, id ASC',
|
||||
[id]
|
||||
'SELECT * FROM emails WHERE bewerbung_id = ? AND user_id = ? ORDER BY datetime(email_date) ASC, id ASC',
|
||||
[id, uid()]
|
||||
);
|
||||
if (emails.length) {
|
||||
const eIds = emails.map((e) => e.id);
|
||||
const atts = await dbAll(
|
||||
`SELECT id, email_id, name, mime FROM email_anhaenge WHERE email_id IN (${eIds.map(() => '?').join(',')})`,
|
||||
eIds
|
||||
`SELECT id, email_id, name, mime FROM email_anhaenge WHERE user_id = ? AND email_id IN (${eIds.map(() => '?').join(',')})`,
|
||||
[uid(), ...eIds]
|
||||
);
|
||||
const byEmail = {};
|
||||
atts.forEach((a) => { (byEmail[a.email_id] = byEmail[a.email_id] || []).push(a); });
|
||||
@@ -376,12 +400,12 @@ function createExternalApi(deps) {
|
||||
try {
|
||||
const { emailId, attachmentId } = req.params;
|
||||
const anhang = await dbGet(
|
||||
'SELECT * FROM email_anhaenge WHERE id = ? AND email_id = ?',
|
||||
[attachmentId, emailId]
|
||||
'SELECT * FROM email_anhaenge WHERE id = ? AND email_id = ? AND user_id = ?',
|
||||
[attachmentId, emailId, uid()]
|
||||
);
|
||||
if (!anhang) return res.status(404).json({ error: 'Anhang nicht gefunden' });
|
||||
|
||||
const file = path.join(emailAnhaengeDir, anhang.pfad);
|
||||
const file = path.join(userStorageDir(emailAnhaengeDir), anhang.pfad);
|
||||
if (!fs.existsSync(file)) return res.status(404).json({ error: 'Datei nicht auf Festplatte vorhanden' });
|
||||
|
||||
res.download(file, anhang.name || path.basename(anhang.pfad));
|
||||
@@ -399,18 +423,19 @@ function createExternalApi(deps) {
|
||||
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||
|
||||
if (typeof (req.body || {}).llm_notizen !== 'undefined') {
|
||||
await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ?', [req.body.llm_notizen || '', id]);
|
||||
await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ? AND user_id = ?', [req.body.llm_notizen || '', id, uid()]);
|
||||
}
|
||||
|
||||
// Drop existing generated attachments + their files before re-generating.
|
||||
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ?', [id]);
|
||||
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
|
||||
const dir = userStorageDir(anhaengeDir);
|
||||
for (const a of alte) {
|
||||
fs.promises.unlink(path.join(anhaengeDir, a.pfad)).catch(() => {});
|
||||
fs.promises.unlink(path.join(dir, a.pfad)).catch(() => {});
|
||||
}
|
||||
await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ?', [id]);
|
||||
await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
|
||||
await dbRun(
|
||||
"UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ?",
|
||||
[id]
|
||||
"UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ? AND user_id = ?",
|
||||
[id, uid()]
|
||||
);
|
||||
|
||||
// Optional: IDs of static attachments (basis_anhaenge) to enclose; none
|
||||
@@ -421,7 +446,7 @@ function createExternalApi(deps) {
|
||||
// Optional: which documents to produce (["anschreiben"], ["lebenslauf"] or
|
||||
// both). Omitted / empty means both.
|
||||
const dokumente = normalizeDokumente((req.body || {}).dokumente);
|
||||
await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ?', [dokumente.join(','), id]);
|
||||
await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ? AND user_id = ?', [dokumente.join(','), id, uid()]);
|
||||
runGeneration(id, { anlagenIds, dokumente });
|
||||
res.status(202).json({ success: true, dokumente });
|
||||
} catch (error) {
|
||||
@@ -434,14 +459,14 @@ function createExternalApi(deps) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const bewerbung = await dbGet(
|
||||
'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ?',
|
||||
[id]
|
||||
'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ? AND user_id = ?',
|
||||
[id, uid()]
|
||||
);
|
||||
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||
|
||||
const anhaenge = await dbAll(
|
||||
'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC',
|
||||
[id]
|
||||
'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC',
|
||||
[id, uid()]
|
||||
);
|
||||
res.json({ status: bewerbung.generierung_status, fehler: bewerbung.generierung_fehler, anhaenge });
|
||||
} catch (error) {
|
||||
@@ -453,7 +478,7 @@ function createExternalApi(deps) {
|
||||
// --- Settings ------------------------------------------------------
|
||||
router.get('/settings', async (req, res) => {
|
||||
try {
|
||||
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
|
||||
const settings = await dbGet('SELECT * FROM settings WHERE user_id = ?', [uid()]);
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
console.error('API get settings error:', error);
|
||||
@@ -465,8 +490,8 @@ function createExternalApi(deps) {
|
||||
try {
|
||||
const { name, adresse, kundennummer } = req.body || {};
|
||||
await dbRun(
|
||||
'UPDATE settings SET name = ?, adresse = ?, kundennummer = ? WHERE id = 1',
|
||||
[sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer)]
|
||||
'UPDATE settings SET name = ?, adresse = ?, kundennummer = ? WHERE user_id = ?',
|
||||
[sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer), uid()]
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -478,17 +503,19 @@ function createExternalApi(deps) {
|
||||
// --- Statistics & export -------------------------------------------
|
||||
router.get('/statistics', async (req, res) => {
|
||||
try {
|
||||
const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen');
|
||||
const byArt = await dbAll(`
|
||||
SELECT art, COUNT(*) as count FROM bewerbungen
|
||||
WHERE art IS NOT NULL AND art != ''
|
||||
GROUP BY art ORDER BY count DESC
|
||||
`);
|
||||
const byStatus = await dbAll(`
|
||||
SELECT status, COUNT(*) as count FROM bewerbungen
|
||||
WHERE status IS NOT NULL AND status != ''
|
||||
GROUP BY status ORDER BY count DESC
|
||||
`);
|
||||
const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen WHERE user_id = ?', [uid()]);
|
||||
const byArt = await dbAll(
|
||||
`SELECT art, COUNT(*) as count FROM bewerbungen
|
||||
WHERE user_id = ? AND art IS NOT NULL AND art != ''
|
||||
GROUP BY art ORDER BY count DESC`,
|
||||
[uid()]
|
||||
);
|
||||
const byStatus = await dbAll(
|
||||
`SELECT status, COUNT(*) as count FROM bewerbungen
|
||||
WHERE user_id = ? AND status IS NOT NULL AND status != ''
|
||||
GROUP BY status ORDER BY count DESC`,
|
||||
[uid()]
|
||||
);
|
||||
res.json({
|
||||
total: totalCount ? totalCount.count : 0,
|
||||
byArt,
|
||||
@@ -503,16 +530,16 @@ function createExternalApi(deps) {
|
||||
router.get('/export', async (req, res) => {
|
||||
try {
|
||||
const { month, year } = req.query;
|
||||
let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC';
|
||||
const params = [];
|
||||
let query = 'SELECT * FROM bewerbungen WHERE user_id = ? ORDER BY datum DESC';
|
||||
const params = [uid()];
|
||||
if (month && year) {
|
||||
query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC';
|
||||
query = 'SELECT * FROM bewerbungen WHERE user_id = ? AND strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC';
|
||||
params.push(String(month).padStart(2, '0'), String(year));
|
||||
} else if (month) {
|
||||
query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? ORDER BY datum DESC';
|
||||
query = 'SELECT * FROM bewerbungen WHERE user_id = ? AND strftime("%m", datum) = ? ORDER BY datum DESC';
|
||||
params.push(String(month).padStart(2, '0'));
|
||||
} else if (year) {
|
||||
query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC';
|
||||
query = 'SELECT * FROM bewerbungen WHERE user_id = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC';
|
||||
params.push(String(year));
|
||||
}
|
||||
|
||||
@@ -530,7 +557,7 @@ function createExternalApi(deps) {
|
||||
// --- Templates -----------------------------------------------------
|
||||
router.get('/templates', async (req, res) => {
|
||||
try {
|
||||
const docs = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
|
||||
const docs = await dbAll('SELECT * FROM basis_dokumente WHERE user_id = ? ORDER BY id ASC', [uid()]);
|
||||
res.json(docs);
|
||||
} catch (error) {
|
||||
console.error('API list templates error:', error);
|
||||
@@ -539,12 +566,14 @@ function createExternalApi(deps) {
|
||||
});
|
||||
|
||||
// --- Job offers (ingested by third-party software) -----------------
|
||||
// POST upserts by (quelle, external_id): re-sending the same offer updates
|
||||
// it instead of creating a duplicate. `quelle` defaults to "drittanbieter".
|
||||
// POST upserts by (user_id, quelle, external_id): re-sending the same offer
|
||||
// updates it instead of creating a duplicate. `quelle` defaults to
|
||||
// "drittanbieter".
|
||||
router.get('/joboffers', async (req, res) => {
|
||||
try {
|
||||
const rows = await dbAll(
|
||||
`SELECT * FROM jobangebote ORDER BY created_at DESC, id DESC`
|
||||
'SELECT * FROM jobangebote WHERE user_id = ? ORDER BY created_at DESC, id DESC',
|
||||
[uid()]
|
||||
);
|
||||
rows.forEach(withLabels);
|
||||
res.json(rows);
|
||||
@@ -560,7 +589,8 @@ function createExternalApi(deps) {
|
||||
router.get('/joboffers/blacklist', async (req, res) => {
|
||||
try {
|
||||
const rows = await dbAll(
|
||||
'SELECT * FROM jobangebote_blacklist ORDER BY created_at DESC, id DESC'
|
||||
'SELECT * FROM jobangebote_blacklist WHERE user_id = ? ORDER BY created_at DESC, id DESC',
|
||||
[uid()]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
@@ -587,10 +617,10 @@ function createExternalApi(deps) {
|
||||
}
|
||||
const cols = blacklist.COLUMNS;
|
||||
const result = await dbRun(
|
||||
`INSERT INTO jobangebote_blacklist (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`,
|
||||
cols.map((c) => (entry[c] === undefined ? null : entry[c]))
|
||||
`INSERT INTO jobangebote_blacklist (user_id, ${cols.join(', ')}) VALUES (?, ${cols.map(() => '?').join(', ')})`,
|
||||
[uid(), ...cols.map((c) => (entry[c] === undefined ? null : entry[c]))]
|
||||
);
|
||||
const row = await dbGet('SELECT * FROM jobangebote_blacklist WHERE id = ?', [result.lastID]);
|
||||
const row = await dbGet('SELECT * FROM jobangebote_blacklist WHERE id = ? AND user_id = ?', [result.lastID, uid()]);
|
||||
res.status(201).json({ success: true, entry: row });
|
||||
} catch (error) {
|
||||
console.error('API create blacklist entry error:', error);
|
||||
@@ -600,9 +630,9 @@ function createExternalApi(deps) {
|
||||
|
||||
router.delete('/joboffers/blacklist/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await dbGet('SELECT id FROM jobangebote_blacklist WHERE id = ?', [req.params.id]);
|
||||
const row = await dbGet('SELECT id FROM jobangebote_blacklist WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||
if (!row) return res.status(404).json({ error: 'Blacklist-Eintrag nicht gefunden' });
|
||||
await dbRun('DELETE FROM jobangebote_blacklist WHERE id = ?', [req.params.id]);
|
||||
await dbRun('DELETE FROM jobangebote_blacklist WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('API delete blacklist entry error:', error);
|
||||
@@ -612,7 +642,7 @@ function createExternalApi(deps) {
|
||||
|
||||
router.get('/joboffers/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]);
|
||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||
if (!row) return res.status(404).json({ error: 'Jobangebot nicht gefunden' });
|
||||
res.json(withLabels(row));
|
||||
} catch (error) {
|
||||
@@ -629,7 +659,7 @@ function createExternalApi(deps) {
|
||||
}
|
||||
|
||||
// Reject blacklisted offers outright — they must never reappear.
|
||||
const blacklistRows = await dbAll('SELECT * FROM jobangebote_blacklist');
|
||||
const blacklistRows = await dbAll('SELECT * FROM jobangebote_blacklist WHERE user_id = ?', [uid()]);
|
||||
const blocked = blacklist.matchBlacklist(blacklistRows, b);
|
||||
if (blocked) {
|
||||
return res.status(409).json({
|
||||
@@ -669,19 +699,20 @@ function createExternalApi(deps) {
|
||||
firmaSlug,
|
||||
];
|
||||
|
||||
// De-dup: prefer a (quelle, external_id) match, else the same normalized
|
||||
// URL — so the same posting never lands twice, even with a new id.
|
||||
// De-dup: prefer a (user_id, quelle, external_id) match, else the same
|
||||
// normalized URL — so the same posting never lands twice, even with a
|
||||
// new id.
|
||||
let existing = null;
|
||||
if (externalId) {
|
||||
existing = await dbGet(
|
||||
'SELECT id FROM jobangebote WHERE quelle = ? AND external_id = ?',
|
||||
[quelle, externalId]
|
||||
'SELECT id FROM jobangebote WHERE user_id = ? AND quelle = ? AND external_id = ?',
|
||||
[uid(), quelle, externalId]
|
||||
);
|
||||
}
|
||||
if (!existing && urlNorm) {
|
||||
existing = await dbGet(
|
||||
'SELECT id FROM jobangebote WHERE url_norm = ? ORDER BY id ASC LIMIT 1',
|
||||
[urlNorm]
|
||||
'SELECT id FROM jobangebote WHERE user_id = ? AND url_norm = ? ORDER BY id ASC LIMIT 1',
|
||||
[uid(), urlNorm]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -689,20 +720,20 @@ function createExternalApi(deps) {
|
||||
await dbRun(
|
||||
`UPDATE jobangebote SET firma = ?, stelle = ?, ort = ?, adresse = ?, ansprechpartner = ?, gehalt = ?,
|
||||
beschreibung = ?, quelle_url = ?, art = ?, anzeige_datum = ?, kontakt_email = ?, status = ?,
|
||||
labels = ?, url_norm = ?, firma_slug = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
[...fields, existing.id]
|
||||
labels = ?, url_norm = ?, firma_slug = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?`,
|
||||
[...fields, existing.id, uid()]
|
||||
);
|
||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [existing.id]);
|
||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [existing.id, uid()]);
|
||||
return res.json({ success: true, action: 'updated', joboffer: withLabels(row) });
|
||||
}
|
||||
|
||||
const result = await dbRun(
|
||||
`INSERT INTO jobangebote
|
||||
(external_id, quelle, firma, stelle, ort, adresse, ansprechpartner, gehalt, beschreibung, quelle_url, art, anzeige_datum, kontakt_email, status, labels, url_norm, firma_slug)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[externalId, quelle, ...fields]
|
||||
(user_id, external_id, quelle, firma, stelle, ort, adresse, ansprechpartner, gehalt, beschreibung, quelle_url, art, anzeige_datum, kontakt_email, status, labels, url_norm, firma_slug)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[uid(), externalId, quelle, ...fields]
|
||||
);
|
||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [result.lastID]);
|
||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [result.lastID, uid()]);
|
||||
res.status(201).json({ success: true, action: 'created', joboffer: withLabels(row) });
|
||||
} catch (error) {
|
||||
console.error('API create job offer error:', error);
|
||||
@@ -714,24 +745,24 @@ function createExternalApi(deps) {
|
||||
// be ingested again; pass ?blacklist=false to hard-delete without blocking.
|
||||
router.delete('/joboffers/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]);
|
||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||
if (!row) return res.status(404).json({ error: 'Jobangebot nicht gefunden' });
|
||||
|
||||
const skipBlacklist = req.query.blacklist === 'false' || req.query.blacklist === '0';
|
||||
let blacklisted = false;
|
||||
if (!skipBlacklist) {
|
||||
const rows = await dbAll('SELECT * FROM jobangebote_blacklist');
|
||||
const rows = await dbAll('SELECT * FROM jobangebote_blacklist WHERE user_id = ?', [uid()]);
|
||||
if (!blacklist.matchBlacklist(rows, row)) {
|
||||
const cols = blacklist.COLUMNS;
|
||||
const entry = blacklist.buildAutoEntry(row, 'Jobangebot gelöscht (REST-API)');
|
||||
await dbRun(
|
||||
`INSERT INTO jobangebote_blacklist (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`,
|
||||
cols.map((c) => (entry[c] === undefined ? null : entry[c]))
|
||||
`INSERT INTO jobangebote_blacklist (user_id, ${cols.join(', ')}) VALUES (?, ${cols.map(() => '?').join(', ')})`,
|
||||
[uid(), ...cols.map((c) => (entry[c] === undefined ? null : entry[c]))]
|
||||
);
|
||||
}
|
||||
blacklisted = true;
|
||||
}
|
||||
await dbRun('DELETE FROM jobangebote WHERE id = ?', [req.params.id]);
|
||||
await dbRun('DELETE FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||
res.json({ success: true, blacklisted });
|
||||
} catch (error) {
|
||||
console.error('API delete job offer error:', error);
|
||||
|
||||
+80
-49
@@ -1,14 +1,19 @@
|
||||
// Centralized configuration store.
|
||||
// Centralized, per-user configuration store.
|
||||
//
|
||||
// Replaces the .env file: all settings live in the SQLite app_state table
|
||||
// (rows prefixed "cfg:") and are editable via the /einstellungen page. The
|
||||
// libs read values through config.get() / config.ollama() at *call* time, so
|
||||
// an edit in the UI takes effect immediately — no restart, no .env file.
|
||||
// Multi-user: every setting (Ollama, E-Mail, CalDAV, API-Token) is owned by a
|
||||
// user. The values live in the SQLite app_state table (key prefixed "cfg:"),
|
||||
// now keyed by (user_id, key). The /einstellungen page edits the *current* user's
|
||||
// values; the libs (mailer, caldav, chat, documents) read through config.get() /
|
||||
// config.ollama() at call time, picking up the current user from the per-request
|
||||
// context (lib/context.js). An edit therefore takes effect immediately, scoped
|
||||
// to the user who made it — no restart, no .env file.
|
||||
//
|
||||
// On first start of an install that previously used .env, init() migrates any
|
||||
// still-present env value into the DB once, so existing config is not lost.
|
||||
// After that the database is the single source of truth; process.env is only a
|
||||
// fallback for keys that were never saved (and for the one-time migration).
|
||||
// still-present env value into the *admin* user's config once. After that the
|
||||
// database is the single source of truth; process.env is only a fallback for keys
|
||||
// that were never saved.
|
||||
|
||||
const { currentUser } = require('./context');
|
||||
|
||||
const DEFAULTS = {
|
||||
// Ollama Cloud (KI text generation + chat).
|
||||
@@ -49,7 +54,7 @@ const FIELDS = [
|
||||
},
|
||||
{
|
||||
titel: 'E-Mail (SMTP-Versand + IMAP-Empfang)',
|
||||
beschreibung: 'Versand läuft über den eigenen Mailserver (DKIM/SPF/DMARC-Alignment). Ohne Host/Benutzer/Passwort ist der E-Mail-Teil deaktiviert.',
|
||||
beschreibung: 'Versand läuft über den eigenen Mailserver (DKIM/SPF/DMARC-Alignment). Ohne Host/Benutzer/Passwort ist der E-Mail-Teil deaktiviert. Pro Benutzer eigenes Postfach.',
|
||||
items: [
|
||||
{ key: 'MAIL_HOST', label: 'SMTP/IMAP Host' },
|
||||
{ key: 'MAIL_SMTP_PORT', label: 'SMTP-Port', help: '587 = STARTTLS, 465 = implicit TLS' },
|
||||
@@ -73,7 +78,7 @@ const FIELDS = [
|
||||
},
|
||||
{
|
||||
titel: 'REST-API für Drittanbietersoftware',
|
||||
beschreibung: 'Ist ein Token gesetzt, ist /api/v1 aktiv und erwartet den Wert im Header „X-API-Key“. Ohne Token antwortet die API (bis auf /health) mit 503. Swagger unter /swagger.',
|
||||
beschreibung: 'Ist ein Token gesetzt, ist /api/v1 für diesen Benutzer aktiv und erwartet den Wert im Header „X-API-Key“. Anfragen operieren auf den Daten dieses Benutzers. Ohne Token antwortet die API (bis auf /health) mit 503. Swagger unter /swagger.',
|
||||
items: [
|
||||
{ key: 'API_TOKEN', label: 'API-Token (X-API-Key)', secret: true, help: 'Leer = API deaktiviert' },
|
||||
],
|
||||
@@ -81,7 +86,12 @@ const FIELDS = [
|
||||
];
|
||||
|
||||
const PREFIX = 'cfg:';
|
||||
const cache = Object.create(null); // key -> string (only keys present in the DB)
|
||||
|
||||
// Per-user cache: Map<userId, Object<string,string>>. A user is loaded lazily on
|
||||
// first access (ensureLoaded) and stays cached for the process lifetime. Edits
|
||||
// via saveAll() update the cache in place so subsequent reads are consistent.
|
||||
const cache = new Map(); // userId -> { key: value }
|
||||
const loaded = new Set(); // userIds whose cfg rows have been read from the DB
|
||||
let dbAllFn = null;
|
||||
let dbRunFn = null;
|
||||
|
||||
@@ -90,22 +100,43 @@ function envOrDefault(key) {
|
||||
return e && e.length ? e : DEFAULTS[key];
|
||||
}
|
||||
|
||||
// Synchronous read. Falls back to process.env (pre-migration / never saved)
|
||||
// then to the built-in default. After init() the DB value is cached and wins.
|
||||
// Load one user's cfg rows from the DB into the cache. No-op if already loaded.
|
||||
async function ensureLoaded(userId) {
|
||||
if (!userId || loaded.has(userId)) return;
|
||||
const rows = await dbAllFn('SELECT key, value FROM app_state WHERE user_id = ?', [userId]);
|
||||
const obj = Object.create(null);
|
||||
for (const r of rows) obj[r.key.slice(PREFIX.length)] = r.value;
|
||||
cache.set(userId, obj);
|
||||
loaded.add(userId);
|
||||
}
|
||||
|
||||
// Drop the cached rows for a user so the next read reloads from the DB. Used
|
||||
// after a direct DB write outside saveAll() (e.g. the one-time env migration).
|
||||
function invalidate(userId) {
|
||||
loaded.delete(userId);
|
||||
cache.delete(userId);
|
||||
}
|
||||
|
||||
// Synchronous read for the current user. Falls back to process.env (pre-migration
|
||||
// / never saved) then to the built-in default. Outside a request context (boot)
|
||||
// only the env/default fallback applies — callers that need a specific user must
|
||||
// run inside the user context (see lib/context.js).
|
||||
function get(key) {
|
||||
const v = cache[key];
|
||||
if (v !== undefined) return v;
|
||||
const u = currentUser();
|
||||
const userObj = u ? cache.get(u.id) : null;
|
||||
if (userObj && userObj[key] !== undefined) return userObj[key];
|
||||
return envOrDefault(key);
|
||||
}
|
||||
|
||||
// All keys with their effective values, keyed by name — used by the settings UI.
|
||||
// All keys with their effective values for the current user — used by the
|
||||
// settings UI. Must be called within a request context.
|
||||
function getAll() {
|
||||
const out = {};
|
||||
for (const key of Object.keys(DEFAULTS)) out[key] = get(key);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Ollama bundle (shared by lib/documents.js + lib/chat.js).
|
||||
// Ollama bundle (shared by lib/documents.js + lib/chat.js), for the current user.
|
||||
function ollama() {
|
||||
return {
|
||||
host: (get('OLLAMA_HOST') || 'https://ollama.com').replace(/\/+$/, ''),
|
||||
@@ -115,49 +146,49 @@ function ollama() {
|
||||
};
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!dbAllFn) return;
|
||||
const rows = await dbAllFn('SELECT key, value FROM app_state WHERE key LIKE ?', [PREFIX + '%']);
|
||||
for (const r of rows) cache[r.key.slice(PREFIX.length)] = r.value;
|
||||
}
|
||||
|
||||
// Wire up DB helpers, load the cached rows, then one-time-migrate any env
|
||||
// value that isn't yet in the DB (so an existing .env install keeps its config).
|
||||
// Wire up DB helpers. The one-time .env migration now runs per-admin at boot
|
||||
// from server.js (it needs the admin user id); this init only stores the fns.
|
||||
async function init({ dbAll, dbRun }) {
|
||||
dbAllFn = dbAll;
|
||||
dbRunFn = dbRun;
|
||||
await load();
|
||||
const toMigrate = [];
|
||||
for (const key of Object.keys(DEFAULTS)) {
|
||||
if (cache[key] === undefined) {
|
||||
const e = process.env[key];
|
||||
if (e && e.length) toMigrate.push([key, e]);
|
||||
}
|
||||
}
|
||||
if (toMigrate.length) {
|
||||
for (const [k, v] of toMigrate) {
|
||||
await dbRunFn(
|
||||
'INSERT INTO app_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
|
||||
[PREFIX + k, v]
|
||||
);
|
||||
cache[k] = v;
|
||||
}
|
||||
console.log(`Konfiguration aus .env in die Datenbank migriert (${toMigrate.length} Werte) — .env wird nicht mehr benötigt.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist every key (writes all rows, including empty strings, so a cleared
|
||||
// field is stored as empty and no longer falls back to env/default).
|
||||
// Persist every key for the current user (writes all rows, including empty
|
||||
// strings, so a cleared field is stored as empty and no longer falls back to
|
||||
// env/default). Must be called within a request context.
|
||||
async function saveAll(values) {
|
||||
if (!dbRunFn) throw new Error('Konfigurationsspeicher nicht initialisiert.');
|
||||
const u = currentUser();
|
||||
if (!u) throw new Error('Kein Benutzerkontext für Konfigurationsspeicherung.');
|
||||
const obj = cache.get(u.id) || Object.create(null);
|
||||
for (const key of Object.keys(DEFAULTS)) {
|
||||
const v = values && values[key] != null ? String(values[key]) : '';
|
||||
await dbRunFn(
|
||||
'INSERT INTO app_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
|
||||
[PREFIX + key, v]
|
||||
'INSERT INTO app_state (user_id, key, value) VALUES (?, ?, ?) ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value',
|
||||
[u.id, PREFIX + key, v]
|
||||
);
|
||||
cache[key] = v;
|
||||
obj[key] = v;
|
||||
}
|
||||
cache.set(u.id, obj);
|
||||
loaded.add(u.id);
|
||||
}
|
||||
|
||||
// Write a single key for an explicit user (used by the one-time env migration,
|
||||
// which runs outside a request context). Updates the cache if loaded.
|
||||
async function setForUser(userId, key, value) {
|
||||
if (!dbRunFn) throw new Error('Konfigurationsspeicher nicht initialisiert.');
|
||||
await dbRunFn(
|
||||
'INSERT INTO app_state (user_id, key, value) VALUES (?, ?, ?) ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value',
|
||||
[userId, PREFIX + key, String(value)]
|
||||
);
|
||||
if (loaded.has(userId)) {
|
||||
const obj = cache.get(userId) || Object.create(null);
|
||||
obj[key] = String(value);
|
||||
cache.set(userId, obj);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DEFAULTS, FIELDS, get, getAll, ollama, init, load, saveAll };
|
||||
module.exports = {
|
||||
DEFAULTS, FIELDS, PREFIX,
|
||||
get, getAll, ollama, init, saveAll, setForUser, ensureLoaded, invalidate,
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
// Per-request user context.
|
||||
//
|
||||
// The web UI authenticates a user via a signed session cookie (see the
|
||||
// /login flow + auth middleware in server.js). The middleware stores the
|
||||
// resolved user object in an AsyncLocalStorage, so any code running during the
|
||||
// request — including the libs (config, mailer, caldav, chat) called deep in
|
||||
// the stack — can read the *current user* without threading it through every
|
||||
// function signature. Background jobs (IMAP poll, CalDAV sync) set the same
|
||||
// context per user while iterating, so each user's config/data is used in turn.
|
||||
//
|
||||
// This is the keystone of the multi-tenant split: config.get() reads the current
|
||||
// user's cfg rows, and the per-user query helpers below filter every SELECT/INSERT
|
||||
// by the current user, guaranteeing isolation between users.
|
||||
|
||||
const { AsyncLocalStorage } = require('async_hooks');
|
||||
|
||||
const userContext = new AsyncLocalStorage();
|
||||
|
||||
// The current user object ({ id, username, is_admin }) or null outside a request
|
||||
// (e.g. during boot). Anything that needs the user id must call this and decide
|
||||
// how to behave when it is absent.
|
||||
function currentUser() {
|
||||
return userContext.getStore() || null;
|
||||
}
|
||||
|
||||
// Convenience: the current user's id, or null when no user is set.
|
||||
function currentUserId() {
|
||||
const u = currentUser();
|
||||
return u ? u.id : null;
|
||||
}
|
||||
|
||||
module.exports = { userContext, currentUser, currentUserId };
|
||||
@@ -0,0 +1,311 @@
|
||||
// Idempotent migration from the single-user schema to the multi-user schema.
|
||||
//
|
||||
// Used both at server boot (server.js calls runMigration after opening the DB)
|
||||
// and by the standalone scripts/migrate-to-multiuser.js. Safe to run repeatedly:
|
||||
// every step guards itself with "already done?" checks.
|
||||
//
|
||||
// What it does, in order:
|
||||
// 1. Create the `users` + `sessions` tables.
|
||||
// 2. Ensure an `admin` user exists (password "admin", scrypt hash). Record its id.
|
||||
// 3. Add a `user_id` column to every per-user table (ALTER ADD COLUMN, nullable
|
||||
// for upgraded installs — fresh installs create it NOT NULL directly) and
|
||||
// backfill every existing row to the admin id.
|
||||
// 4. Recreate the tables whose PRIMARY KEY / UNIQUE must include user_id so the
|
||||
// constraint becomes per-user: app_state, settings, prompts, design,
|
||||
// jobangebote. Old rows are copied to the admin user.
|
||||
// 5. Move on-disk attachment files into a per-user subdirectory for the admin.
|
||||
// 6. One-time migration of any still-present .env values into the admin's cfg.
|
||||
//
|
||||
// After this, the app boots against a fully multi-user schema and all pre-existing
|
||||
// data is owned by the admin user.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const password = require('./password');
|
||||
const config = require('./config');
|
||||
|
||||
// Tables that need a `user_id` column added (legacy upgrades). Fresh installs
|
||||
// create these with user_id NOT NULL directly in initializeDatabase().
|
||||
const USER_TABLES = [
|
||||
'bewerbungen', 'status_verlauf', 'anhaenge', 'interne_anhaenge',
|
||||
'emails', 'email_anhaenge', 'basis_dokumente', 'basis_anhaenge',
|
||||
'termine', 'chat_threads', 'chat_messages',
|
||||
'jobangebote_blacklist',
|
||||
];
|
||||
// jobangebote is NOT in USER_TABLES: its user_id column + per-user UNIQUE
|
||||
// constraint are installed by the recreate in step 4 (which needs user_id to be
|
||||
// absent so the rebuild fires). Adding it here first would make step 4's
|
||||
// !hasColumn guard short-circuit and leave the legacy single-user UNIQUE in
|
||||
// place, breaking per-user isolation on ingest.
|
||||
|
||||
const ADMIN_USERNAME = 'admin';
|
||||
const ADMIN_DEFAULT_PASSWORD = 'admin';
|
||||
|
||||
async function runMigration({ db, dbAll, dbGet, dbRun }) {
|
||||
// Wire the config store to the same DB so config.setForUser works during the
|
||||
// one-time .env migration below.
|
||||
await config.init({ dbAll, dbRun });
|
||||
const exec = (sql) => new Promise((resolve, reject) => db.run(sql, (err) => err ? reject(err) : resolve()));
|
||||
|
||||
const tableExists = async (name) => {
|
||||
const row = await dbGet("SELECT name FROM sqlite_master WHERE type='table' AND name=?", [name]);
|
||||
return !!row;
|
||||
};
|
||||
|
||||
const columnsOf = async (table) => {
|
||||
const rows = await dbAll(`PRAGMA table_info(${table})`);
|
||||
return rows.map((r) => r.name);
|
||||
};
|
||||
|
||||
const hasColumn = async (table, col) => (await columnsOf(table)).includes(col);
|
||||
|
||||
// 1. users + sessions -------------------------------------------------
|
||||
await exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
await exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
await exec('CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)');
|
||||
|
||||
// 2. Ensure admin user (idempotent) -----------------------------------
|
||||
let admin = await dbGet('SELECT id, password_hash FROM users WHERE username = ?', [ADMIN_USERNAME]);
|
||||
if (!admin) {
|
||||
const hash = password.hash(ADMIN_DEFAULT_PASSWORD);
|
||||
const res = await dbRun(
|
||||
'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 1)',
|
||||
[ADMIN_USERNAME, hash]
|
||||
);
|
||||
admin = { id: res.lastID };
|
||||
console.log(`Multi-User-Migration: Admin-Benutzer „${ADMIN_USERNAME}“ angelegt (Passwort „${ADMIN_DEFAULT_PASSWORD}“). Bitte nach erstem Login ändern.`);
|
||||
}
|
||||
const adminId = admin.id;
|
||||
|
||||
// 3. Add user_id column + backfill ------------------------------------
|
||||
for (const tbl of USER_TABLES) {
|
||||
if (!(await tableExists(tbl))) continue; // table may not exist yet on a partial install
|
||||
if (!(await hasColumn(tbl, 'user_id'))) {
|
||||
await exec(`ALTER TABLE ${tbl} ADD COLUMN user_id INTEGER`);
|
||||
}
|
||||
// Backfill every row that is not yet assigned (NULL) to the admin.
|
||||
await dbRun(`UPDATE ${tbl} SET user_id = ? WHERE user_id IS NULL`, [adminId]);
|
||||
}
|
||||
|
||||
// 4. Recreate tables whose PK/UNIQUE must be per-user -----------------
|
||||
// app_state: PK(key) -> PK(user_id, key)
|
||||
if (await tableExists('app_state')) {
|
||||
if (!(await hasColumn('app_state', 'user_id'))) {
|
||||
await recreate(db, dbAll, dbRun, 'app_state',
|
||||
`CREATE TABLE app_state (
|
||||
user_id INTEGER NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT,
|
||||
PRIMARY KEY (user_id, key),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`INSERT INTO app_state (user_id, key, value) SELECT ?, key, value FROM app_state_old`,
|
||||
[adminId]
|
||||
);
|
||||
} else {
|
||||
await dbRun('UPDATE app_state SET user_id = ? WHERE user_id IS NULL', [adminId]);
|
||||
}
|
||||
}
|
||||
|
||||
// prompts: PK(key) -> PK(user_id, key)
|
||||
if (await tableExists('prompts') && !(await hasColumn('prompts', 'user_id'))) {
|
||||
await recreate(db, dbAll, dbRun, 'prompts',
|
||||
`CREATE TABLE prompts (
|
||||
user_id INTEGER NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
inhalt TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, key),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`INSERT INTO prompts (user_id, key, inhalt, updated_at) SELECT ?, key, inhalt, updated_at FROM prompts_old`,
|
||||
[adminId]
|
||||
);
|
||||
} else if (await tableExists('prompts')) {
|
||||
await dbRun('UPDATE prompts SET user_id = ? WHERE user_id IS NULL', [adminId]);
|
||||
}
|
||||
|
||||
// design: PK(key) -> PK(user_id, key)
|
||||
if (await tableExists('design') && !(await hasColumn('design', 'user_id'))) {
|
||||
await recreate(db, dbAll, dbRun, 'design',
|
||||
`CREATE TABLE design (
|
||||
user_id INTEGER NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, key),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`INSERT INTO design (user_id, key, value, updated_at) SELECT ?, key, value, updated_at FROM design_old`,
|
||||
[adminId]
|
||||
);
|
||||
} else if (await tableExists('design')) {
|
||||
await dbRun('UPDATE design SET user_id = ? WHERE user_id IS NULL', [adminId]);
|
||||
}
|
||||
|
||||
// settings: single row CHECK(id=1) -> per-user row (user_id UNIQUE)
|
||||
if (await tableExists('settings') && !(await hasColumn('settings', 'user_id'))) {
|
||||
await recreate(db, dbAll, dbRun, 'settings',
|
||||
`CREATE TABLE settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE,
|
||||
name TEXT,
|
||||
adresse TEXT,
|
||||
kundennummer TEXT,
|
||||
ort TEXT,
|
||||
webseite TEXT,
|
||||
email TEXT,
|
||||
telefon TEXT,
|
||||
geburtsdatum TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`INSERT INTO settings (user_id, name, adresse, kundennummer, ort, webseite, email, telefon, geburtsdatum)
|
||||
SELECT ?, name, adresse, kundennummer, ort, webseite, email, telefon, geburtsdatum FROM settings_old`,
|
||||
[adminId]
|
||||
);
|
||||
} else if (await tableExists('settings')) {
|
||||
await dbRun('UPDATE settings SET user_id = ? WHERE user_id IS NULL', [adminId]);
|
||||
}
|
||||
|
||||
// jobangebote: UNIQUE(quelle, external_id) -> UNIQUE(user_id, quelle, external_id)
|
||||
if (await tableExists('jobangebote') && !(await hasColumn('jobangebote', 'user_id'))) {
|
||||
// user_id was added in step 3 already; this branch only recreates for the
|
||||
// per-user UNIQUE constraint. Rebuild preserving all columns.
|
||||
const cols = (await columnsOf('jobangebote')).filter((c) => c !== 'user_id');
|
||||
const colList = cols.join(', ');
|
||||
await recreate(db, dbAll, dbRun, 'jobangebote',
|
||||
`CREATE TABLE jobangebote (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
external_id TEXT,
|
||||
quelle TEXT NOT NULL DEFAULT 'drittanbieter',
|
||||
firma TEXT NOT NULL,
|
||||
stelle TEXT NOT NULL,
|
||||
ort TEXT,
|
||||
adresse TEXT,
|
||||
ansprechpartner TEXT,
|
||||
gehalt TEXT,
|
||||
beschreibung TEXT,
|
||||
quelle_url TEXT,
|
||||
art TEXT,
|
||||
anzeige_datum DATE,
|
||||
kontakt_email TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'offen',
|
||||
verknuepfte_bewerbung_id INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
url_norm TEXT,
|
||||
firma_slug TEXT,
|
||||
labels TEXT,
|
||||
UNIQUE (user_id, quelle, external_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (verknuepfte_bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL
|
||||
)`,
|
||||
`INSERT INTO jobangebote (user_id, ${colList}) SELECT ?, ${colList} FROM jobangebote_old`,
|
||||
[adminId]
|
||||
);
|
||||
await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_url_norm ON jobangebote(url_norm)');
|
||||
await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_firma_slug ON jobangebote(firma_slug)');
|
||||
}
|
||||
|
||||
// Ensure a settings row exists for the admin (idempotent). On a fresh install
|
||||
// the settings table does not exist yet at this point (it is created by the
|
||||
// server's CREATE TABLE IF NOT EXISTS right after the migration), so skip —
|
||||
// the row is upserted on the admin's first /vorlagen save anyway.
|
||||
if (await tableExists('settings')) {
|
||||
const srow = await dbGet('SELECT id FROM settings WHERE user_id = ?', [adminId]);
|
||||
if (!srow) {
|
||||
await dbRun(
|
||||
`INSERT INTO settings (user_id, name, adresse, kundennummer, ort, webseite, email, telefon, geburtsdatum)
|
||||
VALUES (?, 'Max Mustermann', 'Musterstraße 1, 12345 Musterstadt', '', '', '', '', '', '')`,
|
||||
[adminId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Move on-disk files into a per-user subdirectory for the admin -----
|
||||
moveFilesIntoUserSubdir(adminId);
|
||||
|
||||
// 6. One-time .env -> admin cfg migration ------------------------------
|
||||
await migrateEnvForAdmin(dbAll, adminId);
|
||||
|
||||
return { adminId };
|
||||
}
|
||||
|
||||
// Rename `table` to `table_old`, create the new table from `newSchemaSql`,
|
||||
// copy rows via `copySql` (with `copyParams`), then drop `table_old`.
|
||||
async function recreate(db, dbAll, dbRun, table, newSchemaSql, copySql, copyParams) {
|
||||
const exec = (sql) => new Promise((resolve, reject) => db.run(sql, (err) => err ? reject(err) : resolve()));
|
||||
await exec(`ALTER TABLE ${table} RENAME TO ${table}_old`);
|
||||
await exec(newSchemaSql);
|
||||
await dbRun(copySql, copyParams || []);
|
||||
await exec(`DROP TABLE ${table}_old`);
|
||||
}
|
||||
|
||||
// Move every file in each per-user storage directory into a `<userId>/` subdir.
|
||||
// Idempotent: if the subdir already contains files (already migrated), leave the
|
||||
// top-level files alone (they would be a re-run left-overs) — but on first run the
|
||||
// top level holds the legacy flat files, which we move in.
|
||||
const STORAGE_DIRS = ['anhaenge', 'basis_anhaenge', 'interne_anhaenge', 'email_anhaenge', 'signatur', 'bewerberfoto'];
|
||||
function moveFilesIntoUserSubdir(userId) {
|
||||
const dataDir = path.join(__dirname, '..', 'data');
|
||||
for (const dir of STORAGE_DIRS) {
|
||||
const base = path.join(dataDir, dir);
|
||||
if (!fs.existsSync(base)) continue;
|
||||
const userDir = path.join(base, String(userId));
|
||||
if (!fs.existsSync(userDir)) fs.mkdirSync(userDir, { recursive: true });
|
||||
for (const name of fs.readdirSync(base)) {
|
||||
if (name === String(userId)) continue;
|
||||
const src = path.join(base, name);
|
||||
if (!fs.statSync(src).isFile()) continue; // skip subdirectories
|
||||
const dst = path.join(userDir, name);
|
||||
if (!fs.existsSync(dst)) fs.renameSync(src, dst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Move any still-present .env value for a DEFAULT key that the admin hasn't got
|
||||
// stored yet into the admin's cfg rows. Idempotent: only fills keys that are not
|
||||
// yet present for the admin.
|
||||
async function migrateEnvForAdmin(dbAll, adminId) {
|
||||
// On a fresh install app_state does not exist yet at this point (created by
|
||||
// the server right after the migration), so there is nothing to read from or
|
||||
// write to — skip. The .env values, if any, then fall back via config.get().
|
||||
try {
|
||||
await dbAll('SELECT 1 FROM app_state LIMIT 1');
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
const storedRows = await dbAll('SELECT key FROM app_state WHERE user_id = ?', [adminId]);
|
||||
const stored = new Set(storedRows.map((r) => r.key.slice(config.PREFIX.length)));
|
||||
const migrated = [];
|
||||
for (const key of Object.keys(config.DEFAULTS)) {
|
||||
if (stored.has(key)) continue; // already in the DB, never overwrite from .env
|
||||
const envVal = process.env[key];
|
||||
if (envVal && envVal.length) {
|
||||
await config.setForUser(adminId, key, envVal);
|
||||
migrated.push(key);
|
||||
}
|
||||
}
|
||||
if (migrated.length) {
|
||||
console.log(`Multi-User-Migration: ${migrated.length} Konfigurationswerte aus .env in den Admin-Benutzer migriert — .env wird nicht mehr benötigt.`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { runMigration, moveFilesIntoUserSubdir, STORAGE_DIRS, ADMIN_USERNAME, ADMIN_DEFAULT_PASSWORD };
|
||||
@@ -0,0 +1,34 @@
|
||||
// Password hashing using Node's built-in scrypt + a per-hash random salt.
|
||||
//
|
||||
// No external dependency (bcrypt would need a native build step). scrypt is
|
||||
// memory-hard and well suited for interactive logins. Hash format:
|
||||
// "<saltHex>:<hashHex>" (salt is 16 bytes, hash is 64 bytes)
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const KEYLEN = 64;
|
||||
|
||||
function hash(password) {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const out = crypto.scryptSync(password, salt, KEYLEN).toString('hex');
|
||||
return `${salt}:${out}`;
|
||||
}
|
||||
|
||||
function verify(password, stored) {
|
||||
if (typeof stored !== 'string' || !stored.includes(':')) return false;
|
||||
const idx = stored.indexOf(':');
|
||||
const salt = stored.slice(0, idx);
|
||||
const expected = stored.slice(idx + 1);
|
||||
if (!salt || !expected) return false;
|
||||
let computed;
|
||||
try {
|
||||
computed = crypto.scryptSync(password, salt, KEYLEN).toString('hex');
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
if (computed.length !== expected.length) return false;
|
||||
// Constant-time compare to avoid timing side channels.
|
||||
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expected));
|
||||
}
|
||||
|
||||
module.exports = { hash, verify };
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env node
|
||||
// Standalone one-shot migration from the single-user schema to the multi-user
|
||||
// schema. Idempotent (safe to run repeatedly). Run it once on an existing
|
||||
// single-user install before (or alongside) the first boot of the multi-user
|
||||
// server — the server runs the same migration at boot too, so this script is
|
||||
// mainly a convenience for migrating without starting the server.
|
||||
//
|
||||
// node scripts/migrate-to-multiuser.js [path/to/bewerbungen.db]
|
||||
//
|
||||
// All existing data is assigned to the admin user (admin:admin). Change the
|
||||
// admin password after first login.
|
||||
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const { runMigration, ADMIN_USERNAME, ADMIN_DEFAULT_PASSWORD } = require('../lib/migrate-multiuser');
|
||||
|
||||
const dbPath = process.argv[2]
|
||||
? path.resolve(process.argv[2])
|
||||
: path.join(__dirname, '..', 'data', 'bewerbungen.db');
|
||||
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
// Promise wrappers matching the server's helpers.
|
||||
const dbGet = (sql, params = []) => new Promise((resolve, reject) => db.get(sql, params, (err, row) => err ? reject(err) : resolve(row)));
|
||||
const dbAll = (sql, params = []) => new Promise((resolve, reject) => db.all(sql, params, (err, rows) => err ? reject(err) : resolve(rows)));
|
||||
const dbRun = (sql, params = []) => new Promise((resolve, reject) => db.run(sql, params, function (err) { err ? reject(err) : resolve({ lastID: this.lastID, changes: this.changes }); }));
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
console.log(`Multi-User-Migration startet für Datenbank: ${dbPath}`);
|
||||
const { adminId } = await runMigration({ db, dbAll, dbGet, dbRun });
|
||||
console.log(`Multi-User-Migration abgeschlossen. Alle Daten gehören jetzt dem Benutzer „${ADMIN_USERNAME}“ (id ${adminId}).`);
|
||||
console.log(`Bitte nach dem ersten Login das Passwort „${ADMIN_DEFAULT_PASSWORD}“ ändern.`);
|
||||
db.close();
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error('Multi-User-Migration fehlgeschlagen:', err);
|
||||
db.close();
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<%- include('partials/head') %>
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
||||
<%- include('partials/header') %>
|
||||
|
||||
<main class="flex-1 container mx-auto px-4 py-8 max-w-4xl">
|
||||
<a href="/" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
|
||||
</svg>
|
||||
Zurück zur Übersicht
|
||||
</a>
|
||||
|
||||
<h2 class="text-2xl font-bold mb-1">Benutzerverwaltung</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">Benutzer anlegen, Passwörter zurücksetzen und Benutzer inkl. aller ihrer Daten löschen.</p>
|
||||
|
||||
<!-- Neuen Benutzer anlegen -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
||||
<h3 class="text-lg font-semibold mb-4">Neuen Benutzer anlegen</h3>
|
||||
<form method="POST" action="/admin/users" class="grid sm:grid-cols-3 gap-4 items-end">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="newUsername">Benutzername</label>
|
||||
<input id="newUsername" name="username" type="text" required maxlength="64"
|
||||
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="newPassword">Passwort</label>
|
||||
<input id="newPassword" name="password" type="password" required
|
||||
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" />
|
||||
</div>
|
||||
<div class="flex items-end gap-4">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input type="checkbox" name="is_admin" value="1" class="rounded border-gray-300 dark:border-gray-600" />
|
||||
Admin
|
||||
</label>
|
||||
<button type="submit" class="rounded-lg bg-blue-600 hover:bg-blue-700 transition-colors text-white font-medium px-4 py-2">
|
||||
Anlegen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bestehende Benutzer -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Bestehende Benutzer</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
|
||||
<th class="py-2 pr-4 font-medium">Benutzername</th>
|
||||
<th class="py-2 pr-4 font-medium">Rolle</th>
|
||||
<th class="py-2 pr-4 font-medium">Bewerbungen</th>
|
||||
<th class="py-2 pr-4 font-medium">Erstellt</th>
|
||||
<th class="py-2 pr-4 font-medium text-right">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<% users.forEach(function(u) { %>
|
||||
<tr>
|
||||
<td class="py-3 pr-4 font-medium text-gray-800 dark:text-gray-100">
|
||||
<%= u.username %>
|
||||
<% if (u.id === currentUserId) { %><span class="text-xs text-gray-400">(du)</span><% } %>
|
||||
</td>
|
||||
<td class="py-3 pr-4">
|
||||
<% if (u.is_admin) { %>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">Admin</span>
|
||||
<% } else { %>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">Benutzer</span>
|
||||
<% } %>
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-gray-600 dark:text-gray-300"><%= u.anzahl_bewerbungen %></td>
|
||||
<td class="py-3 pr-4 text-gray-500 dark:text-gray-400"><%= u.created_at %></td>
|
||||
<td class="py-3 pr-4 text-right whitespace-nowrap">
|
||||
<button type="button"
|
||||
onclick="document.getElementById('resetForm<%= u.id %>').classList.toggle('hidden')"
|
||||
class="text-sm text-blue-600 dark:text-blue-400 hover:underline mr-3">
|
||||
Passwort
|
||||
</button>
|
||||
<% if (u.id !== currentUserId) { %>
|
||||
<form method="POST" action="/admin/users/<%= u.id %>/delete" class="inline"
|
||||
onsubmit="return confirm('Benutzer „<%= u.username %>“ inkl. aller Daten wirklich löschen? Das lässt sich nicht rückgängig machen.');">
|
||||
<button type="submit" class="text-sm text-red-600 dark:text-red-400 hover:underline">Löschen</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="resetForm<%= u.id %>" class="hidden">
|
||||
<td colspan="5" class="py-3 pr-4">
|
||||
<form method="POST" action="/admin/users/<%= u.id %>/reset-password" class="flex items-end gap-3 flex-wrap">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Neues Passwort
|
||||
<input name="password" type="password" required
|
||||
class="mt-1 block w-full sm:w-64 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" />
|
||||
</label>
|
||||
<button type="submit" class="rounded-lg bg-blue-600 hover:bg-blue-700 transition-colors text-white font-medium px-4 py-2">
|
||||
Speichern (alle Sessions ungültigen)
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" class="dark">
|
||||
<head>
|
||||
<%- include('partials/head') %>
|
||||
<style>
|
||||
body { background: linear-gradient(135deg, #1e3a8a 0%, #1f2937 100%); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl p-8">
|
||||
<div class="flex flex-col items-center mb-6">
|
||||
<svg class="w-12 h-12 text-blue-600 dark:text-blue-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
<h1 class="text-xl font-bold text-gray-800 dark:text-white">Bewerbungs-Tracker</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Bitte anmelden</p>
|
||||
</div>
|
||||
|
||||
<% if (error) { %>
|
||||
<div class="mb-4 rounded-md bg-red-50 dark:bg-red-900/30 px-4 py-2.5 text-sm text-red-700 dark:text-red-300 ring-1 ring-red-200 dark:ring-red-800">
|
||||
<%= error %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<form method="POST" action="/login" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="username">Benutzername</label>
|
||||
<input id="username" name="username" type="text" required autofocus
|
||||
value="<%= username %>"
|
||||
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="password">Passwort</label>
|
||||
<input id="password" name="password" type="password" required
|
||||
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" />
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full rounded-lg bg-blue-600 hover:bg-blue-700 transition-colors text-white font-medium py-2.5">
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="text-center text-xs text-white/70 mt-6">Multi-User-Plattform</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -63,6 +63,18 @@
|
||||
<span class="hidden sm:inline">Einstellungen</span>
|
||||
</a>
|
||||
|
||||
<% if (typeof user !== 'undefined' && user && user.is_admin) { %>
|
||||
<!-- Admin: user management -->
|
||||
<a href="/admin"
|
||||
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
|
||||
title="Benutzer verwalten">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-2a4 4 0 100-8 4 4 0 000 8z"></path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Benutzer</span>
|
||||
</a>
|
||||
<% } %>
|
||||
|
||||
<!-- Notification bell: unread received e-mails (incl. auto-assigned replies) -->
|
||||
<div class="relative" id="notifWrap">
|
||||
<button id="notifBtn" type="button"
|
||||
@@ -82,6 +94,21 @@
|
||||
<div id="notifEmpty" class="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">Keine ungelesenen Nachrichten</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (typeof user !== 'undefined' && user) { %>
|
||||
<!-- Signed-in user + logout -->
|
||||
<div class="flex items-center gap-2 pl-1">
|
||||
<span class="text-white/90 text-sm font-medium hidden sm:inline"><%= user.username %><% if (user.is_admin) { %> <span class="text-white/60">(Admin)</span><% } %></span>
|
||||
<a href="/logout"
|
||||
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
|
||||
title="Abmelden">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Abmelden</span>
|
||||
</a>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user