diff --git a/Dockerfile b/Dockerfile
index d71bca8..f2416a6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,9 +6,8 @@ WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
-COPY server ./server
-COPY public ./public
-COPY database ./database
+# Anwendungsdateien kopieren
+COPY . .
ENV NODE_ENV=production
ENV PORT=8888
diff --git a/data/crm.db b/data/crm.db
index 9745724..596f564 100644
Binary files a/data/crm.db and b/data/crm.db differ
diff --git a/database/init.sql b/database/init.sql
index e9e4af6..8cd558e 100644
--- a/database/init.sql
+++ b/database/init.sql
@@ -69,6 +69,8 @@ CREATE TABLE IF NOT EXISTS "users" (
"role" TEXT NOT NULL DEFAULT 'user' CHECK ("role" IN ('admin', 'user')),
"source" TEXT NOT NULL DEFAULT 'local' CHECK ("source" IN ('local', 'ldap')),
"ldap_dn" TEXT,
+ "firstname" TEXT,
+ "lastname" TEXT,
"active" INTEGER NOT NULL DEFAULT 1 CHECK ("active" IN (0, 1)),
"created_at" TEXT NOT NULL DEFAULT (datetime('now')),
"updated_at" TEXT NOT NULL DEFAULT (datetime('now'))
diff --git a/docker-compose.yml b/docker-compose.yml
index e155e96..9d1d246 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -4,14 +4,12 @@ services:
image: sds-crm:latest
restart: unless-stopped
ports:
- - "${PORT:-8888}:8888"
+ - "8888:8888" # Hauptserver
environment:
- PORT: ${PORT:-8888}
# Persistente SQLite-Datei und Uploads auf dem Host (Volume unten)
- SQLITE_PATH: /data/crm.db
- UPLOAD_DIR: /data/uploads
NODE_ENV: production
SESSION_SECRET: ${SESSION_SECRET:-}
volumes:
- # Host-Verzeichnis: hier liegt die Datenbank (und uploads/) dauerhaft
- - ${CRM_DATA_DIR:-./docker-data}:/data
+ # Datenbank: Verzeichnis mounten (nicht einzelne Datei – sonst erzeugt Docker ein Verzeichnis)
+ - ./data:/app/data
+ - ./uploads:/app/server/data/uploads
diff --git a/public/css/style.css b/public/css/style.css
index d67f41b..e48326f 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -88,6 +88,18 @@ a:hover { text-decoration: underline; }
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
+ flex: 1;
+ min-width: 0;
+}
+
+.nav-user-area {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+.nav-user-area .nav-user {
+ white-space: nowrap;
}
.nav-user {
diff --git a/public/js/core/layout.js b/public/js/core/layout.js
index f45b3b2..5069214 100644
--- a/public/js/core/layout.js
+++ b/public/js/core/layout.js
@@ -1,6 +1,14 @@
import { apiPost } from '../api.js';
import { esc } from './utils.js';
+function navDisplayName(user) {
+ if (!user) return '';
+ const a = [user.firstName, user.lastName].filter(
+ (x) => x != null && String(x).trim() !== '',
+ );
+ return a.length ? a.map((x) => String(x).trim()).join(' ') : user.username;
+}
+
/**
* @param {string} [activeNav] 'start' | 'machines' | 'tickets' | 'options' | 'users'
*/
@@ -22,8 +30,10 @@ export function updateNav(st, activeNav = '') {
? `OptionenBenutzer`
: ''
}
- ${esc(st.user.username)}
- `;
+
+ ${esc(navDisplayName(st.user))}
+
+
`;
const btn = document.getElementById('btn-logout');
if (btn) {
btn.onclick = async () => {
diff --git a/public/js/pages/options.js b/public/js/pages/options.js
index 51d7b0d..ffe18fd 100644
--- a/public/js/pages/options.js
+++ b/public/js/pages/options.js
@@ -50,7 +50,7 @@ function applyIntegrationForm(data) {
document.getElementById('ldap_lastNameAttribute').value =
ldap.lastNameAttribute ?? '';
document.getElementById('ldap_syncIntervalMinutes').value = String(
- ldap.syncIntervalMinutes ?? 1440,
+ ldap.syncIntervalMinutes ?? 0,
);
const tv = data.teamviewer || {};
@@ -87,7 +87,12 @@ async function run() {
const btn = document.getElementById('btn-ldap-sync-now');
btn.disabled = true;
try {
- await apiPost('/ldap/sync', {});
+ const r = await apiPost('/ldap/sync', {});
+ if (r.errors && r.errors.length) {
+ alert(r.errors.join('\n'));
+ } else if (r.ok === false && r.error) {
+ alert(r.error);
+ }
location.reload();
} catch (err) {
alert(err.message || String(err));
diff --git a/public/js/pages/users.js b/public/js/pages/users.js
index d6d95e8..1c4450d 100644
--- a/public/js/pages/users.js
+++ b/public/js/pages/users.js
@@ -13,6 +13,11 @@ function showError(msg) {
errEl.textContent = msg;
}
+function formatName(u) {
+ const a = [u.firstName, u.lastName].filter(Boolean);
+ return a.length ? a.map((x) => esc(String(x))).join(' ') : '—';
+}
+
function renderRows(users) {
const tbody = document.getElementById('users-table-body');
tbody.innerHTML = users
@@ -20,6 +25,7 @@ function renderRows(users) {
(u) => `
| ${esc(u.username)} |
+ ${formatName(u)} |
${u.role === 'admin' ? 'Admin' : 'Benutzer'} |
${u.source === 'ldap' ? 'LDAP' : 'Lokal'} |
${u.active ? 'Ja' : 'Nein'} |
diff --git a/public/users.html b/public/users.html
index 2edd086..d9c8b40 100644
--- a/public/users.html
+++ b/public/users.html
@@ -43,6 +43,7 @@
| Benutzer |
+ Name |
Rolle |
Quelle |
Aktiv |
diff --git a/server/db.js b/server/db.js
index 79ebe3a..f4e0023 100644
--- a/server/db.js
+++ b/server/db.js
@@ -131,6 +131,8 @@ if (!tbl) {
"role" TEXT NOT NULL DEFAULT 'user' CHECK ("role" IN ('admin', 'user')),
"source" TEXT NOT NULL DEFAULT 'local' CHECK ("source" IN ('local', 'ldap')),
"ldap_dn" TEXT,
+ "firstname" TEXT,
+ "lastname" TEXT,
"active" INTEGER NOT NULL DEFAULT 1 CHECK ("active" IN (0, 1)),
"created_at" TEXT NOT NULL DEFAULT (datetime('now')),
"updated_at" TEXT NOT NULL DEFAULT (datetime('now'))
@@ -139,6 +141,14 @@ if (!tbl) {
`);
}
+const userCols = db.prepare('PRAGMA table_info(users)').all();
+if (!userCols.some((c) => c.name === 'firstname')) {
+ db.exec('ALTER TABLE users ADD COLUMN firstname TEXT');
+}
+if (!userCols.some((c) => c.name === 'lastname')) {
+ db.exec('ALTER TABLE users ADD COLUMN lastname TEXT');
+}
+
const tblSet = db
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'",
diff --git a/server/index.js b/server/index.js
index 36af725..cee3449 100644
--- a/server/index.js
+++ b/server/index.js
@@ -14,7 +14,7 @@ dotenv.config();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
-const PORT = process.env.PORT || 3000;
+const PORT = process.env.PORT || 8888;
app.set('trust proxy', 1);
app.use(
diff --git a/server/integrations.js b/server/integrations.js
index b0171c2..afa9217 100644
--- a/server/integrations.js
+++ b/server/integrations.js
@@ -12,7 +12,7 @@ const DEFAULT_INTEGRATIONS = {
usernameAttribute: 'sAMAccountName',
firstNameAttribute: 'givenName',
lastNameAttribute: 'sn',
- syncIntervalMinutes: 1440,
+ syncIntervalMinutes: 0,
syncEnabled: false,
syncNotes: '',
},
@@ -50,20 +50,41 @@ export function saveIntegrations(obj) {
).run(json);
}
-let ldapSyncTimer = null;
+let ldapSchedulerTimer = null;
+/** Wie Stundenerfassung: alle 5 Minuten prüfen, ob eine Synchronisation fällig ist. */
export function restartLdapSyncScheduler() {
- if (ldapSyncTimer) {
- clearInterval(ldapSyncTimer);
- ldapSyncTimer = null;
+ if (ldapSchedulerTimer) {
+ clearInterval(ldapSchedulerTimer);
+ ldapSchedulerTimer = null;
}
- const cfg = loadIntegrations().ldap;
- if (!cfg.syncEnabled) return;
- const m = Math.max(0, Number(cfg.syncIntervalMinutes) || 0);
- if (m <= 0) return;
- ldapSyncTimer = setInterval(() => {
- performLdapSync(db, loadIntegrations, 'automatic').catch((err) =>
- console.error('LDAP auto-sync:', err),
- );
- }, m * 60 * 1000);
+ ldapSchedulerTimer = setInterval(() => {
+ const cfg = loadIntegrations().ldap;
+ if (!cfg.syncEnabled) return;
+ const intervalMin = Math.max(0, Number(cfg.syncIntervalMinutes) || 0);
+ if (intervalMin <= 0) return;
+
+ const row = db
+ .prepare("SELECT value FROM app_settings WHERE key = 'ldap_last_sync_at'")
+ .get();
+ const lastSync = row?.value ? new Date(row.value) : null;
+ const now = new Date();
+ const syncIntervalMs = intervalMin * 60 * 1000;
+
+ if (!lastSync || now - lastSync >= syncIntervalMs) {
+ console.log('Starte automatische LDAP-Synchronisation…');
+ performLdapSync(db, loadIntegrations, 'automatic')
+ .then((r) => {
+ if (r.skipped) return;
+ if (r.ok) {
+ console.log(
+ `Automatische LDAP-Synchronisation abgeschlossen: ${r.usersSynced} Benutzer synchronisiert`,
+ );
+ } else {
+ console.error('LDAP auto-sync:', r.error || r.errors);
+ }
+ })
+ .catch((err) => console.error('LDAP auto-sync:', err));
+ }
+ }, 5 * 60 * 1000);
}
diff --git a/server/ldap-auth.js b/server/ldap-auth.js
new file mode 100644
index 0000000..03cda38
--- /dev/null
+++ b/server/ldap-auth.js
@@ -0,0 +1,277 @@
+/**
+ * LDAP-Anmeldung analog zu Stundenerfassung (services/ldap-service.js):
+ * Service-Bind, Benutzersuche (exakt + Varianten ß/ss), Bind mit Nutzerpasswort.
+ */
+import ldap from 'ldapjs';
+
+function escapeLDAPFilter(value) {
+ if (!value) return '';
+ return String(value)
+ .replace(/\\/g, '\\5c')
+ .replace(/\*/g, '\\2a')
+ .replace(/\(/g, '\\28')
+ .replace(/\)/g, '\\29')
+ .replace(/\0/g, '\\00');
+}
+
+function getUsernameSearchVariants(usernameStr) {
+ const variants = new Set();
+ variants.add(usernameStr);
+ variants.add(usernameStr.toLowerCase());
+ variants.add(usernameStr.replace(/\u00df/g, 'ss'));
+ variants.add(usernameStr.replace(/ss/g, '\u00df'));
+ return [...variants];
+}
+
+function unescapeLdapDN(dn) {
+ if (!dn || typeof dn !== 'string') return dn;
+ let result = '';
+ const bytes = [];
+ let i = 0;
+ while (i < dn.length) {
+ if (dn[i] === '\\' && i + 2 < dn.length && /^[0-9a-fA-F]{2}$/.test(dn.slice(i + 1, i + 3))) {
+ bytes.push(parseInt(dn.slice(i + 1, i + 3), 16));
+ i += 3;
+ } else {
+ if (bytes.length > 0) {
+ result += Buffer.from(bytes).toString('utf8');
+ bytes.length = 0;
+ }
+ result += dn[i];
+ i++;
+ }
+ }
+ if (bytes.length > 0) {
+ result += Buffer.from(bytes).toString('utf8');
+ }
+ return result;
+}
+
+function getAttributeValue(entry, attributeName) {
+ const want = String(attributeName || '').toLowerCase();
+ for (const attr of entry.attributes || []) {
+ if (attr.type.toLowerCase() === want) {
+ const value = Array.isArray(attr.values) ? attr.values[0] : attr.values;
+ return value != null ? String(value) : null;
+ }
+ }
+ return null;
+}
+
+function entryUserDn(entry) {
+ try {
+ if (entry.dn != null) return entry.dn.toString();
+ if (entry.objectName != null) return entry.objectName.toString();
+ } catch {
+ /* ignore */
+ }
+ return '';
+}
+
+function bindAsync(client, dn, password) {
+ return new Promise((resolve, reject) => {
+ client.bind(dn || '', password ?? '', (err) => (err ? reject(err) : resolve()));
+ });
+}
+
+function unbindAsync(client) {
+ return new Promise((resolve) => {
+ try {
+ client.unbind(() => resolve());
+ } catch {
+ resolve();
+ }
+ });
+}
+
+function searchWithEvents(client, baseDN, options) {
+ return new Promise((resolve, reject) => {
+ client.search(baseDN, options, (err, res) => {
+ if (err) return reject(err);
+ const entries = [];
+ res.on('searchEntry', (entry) => entries.push(entry));
+ res.on('error', reject);
+ res.on('end', (result) => {
+ if (result && result.status !== 0) {
+ return reject(new Error(`LDAP-Suche fehlgeschlagen: ${result.status}`));
+ }
+ resolve(entries);
+ });
+ });
+ });
+}
+
+/**
+ * @param {string} usernameInput Roher Benutzername (Trim, kein toLowerCase — für UTF-8/AD)
+ * @param {string} password
+ * @param {() => object} loadIntegrations
+ * @returns {Promise<{ ok: true, canonicalUsername: string } | { ok: false, skipLdap?: true, ldapAttempted?: true, message?: string }>}
+ */
+export async function authenticateLdap(usernameInput, password, loadIntegrations) {
+ const usernameStr = String(usernameInput || '').trim();
+ if (!usernameStr) {
+ return {
+ ok: false,
+ ldapAttempted: true,
+ message: 'Benutzername darf nicht leer sein',
+ };
+ }
+
+ const raw = loadIntegrations().ldap || {};
+ if (!raw.syncEnabled) {
+ return { ok: false, skipLdap: true };
+ }
+
+ const url = String(raw.serverUrl || '').trim();
+ const baseDN = String(raw.searchBase || '').trim();
+ if (!url || !baseDN) {
+ return { ok: false, skipLdap: true };
+ }
+
+ const usernameAttr = String(raw.usernameAttribute || 'sAMAccountName').trim();
+
+ const client = ldap.createClient({
+ url,
+ timeout: 10000,
+ connectTimeout: 10000,
+ reconnect: false,
+ });
+ client.on('error', () => {});
+
+ try {
+ await bindAsync(
+ client,
+ String(raw.bindDn || '').trim(),
+ raw.bindPassword != null ? String(raw.bindPassword) : '',
+ );
+ } catch (e) {
+ await unbindAsync(client);
+ return {
+ ok: false,
+ ldapAttempted: true,
+ message: e && e.message ? String(e.message) : String(e),
+ };
+ }
+
+ const exactFilter = `(${usernameAttr}=${escapeLDAPFilter(usernameStr)})`;
+ let userDN = null;
+ let canonicalUsername = null;
+
+ const applyEntries = (entries, fallbackName) => {
+ for (const entry of entries) {
+ const dn = entryUserDn(entry);
+ if (dn) {
+ userDN = dn;
+ canonicalUsername =
+ getAttributeValue(entry, usernameAttr) || fallbackName;
+ }
+ }
+ };
+
+ const runVariantSearch = async () => {
+ userDN = null;
+ canonicalUsername = null;
+ const variants = getUsernameSearchVariants(usernameStr);
+ const filterParts = variants.map(
+ (v) => `(${usernameAttr}=${escapeLDAPFilter(v)})`,
+ );
+ const searchFilter =
+ filterParts.length === 1 ? filterParts[0] : `(|${filterParts.join('')})`;
+ const entries = await searchWithEvents(client, baseDN, {
+ filter: searchFilter,
+ scope: 'sub',
+ attributes: ['dn', usernameAttr],
+ });
+ applyEntries(entries, usernameStr);
+ };
+
+ try {
+ const entries = await searchWithEvents(client, baseDN, {
+ filter: exactFilter,
+ scope: 'sub',
+ attributes: ['dn', usernameAttr],
+ });
+ applyEntries(entries, usernameStr);
+ } catch {
+ try {
+ await runVariantSearch();
+ } catch (e) {
+ await unbindAsync(client);
+ return {
+ ok: false,
+ ldapAttempted: true,
+ message: e && e.message ? String(e.message) : String(e),
+ };
+ }
+ }
+
+ if (!userDN) {
+ try {
+ await runVariantSearch();
+ } catch (e) {
+ await unbindAsync(client);
+ return {
+ ok: false,
+ ldapAttempted: true,
+ message: e && e.message ? String(e.message) : String(e),
+ };
+ }
+ }
+
+ if (!userDN) {
+ await unbindAsync(client);
+ return {
+ ok: false,
+ ldapAttempted: true,
+ message: `Benutzer „${usernameStr}“ nicht gefunden. Prüfen Sie den Benutzernamen (z. B. UTF-8-Zeichen).`,
+ };
+ }
+
+ await unbindAsync(client);
+
+ const bindDN = unescapeLdapDN(userDN);
+ const authClient = ldap.createClient({
+ url,
+ timeout: 10000,
+ connectTimeout: 10000,
+ reconnect: false,
+ });
+
+ try {
+ await new Promise((resolve, reject) => {
+ authClient.on('error', (err) => {
+ try {
+ authClient.unbind();
+ } catch {
+ /* ignore */
+ }
+ reject(err);
+ });
+ authClient.bind(bindDN, password, (err) => {
+ if (err) {
+ const errorMsg = err.message || String(err);
+ try {
+ authClient.unbind();
+ } catch {
+ /* ignore */
+ }
+ return reject(
+ new Error(`Ungültiges Passwort oder Authentifizierungsfehler: ${errorMsg}`),
+ );
+ }
+ authClient.unbind(() => resolve());
+ });
+ });
+ } catch (e) {
+ return {
+ ok: false,
+ ldapAttempted: true,
+ message: e && e.message ? String(e.message) : String(e),
+ };
+ }
+
+ return {
+ ok: true,
+ canonicalUsername: canonicalUsername || usernameStr,
+ };
+}
diff --git a/server/ldap-sync.js b/server/ldap-sync.js
index f358f6b..54ce48a 100644
--- a/server/ldap-sync.js
+++ b/server/ldap-sync.js
@@ -1,5 +1,6 @@
import { randomUUID } from 'crypto';
import ldap from 'ldapjs';
+import { hashPassword } from './password.js';
let syncRunning = false;
@@ -7,13 +8,23 @@ function getAttr(entry, name) {
const want = String(name || '').toLowerCase();
for (const attr of entry.attributes) {
if (attr.type.toLowerCase() === want) {
- const v = attr.values[0];
+ const v = Array.isArray(attr.values) ? attr.values[0] : attr.values;
return v != null ? String(v).trim() : '';
}
}
return '';
}
+function entryDn(entry) {
+ try {
+ if (entry.objectName != null) return entry.objectName.toString();
+ if (entry.dn != null) return entry.dn.toString();
+ } catch {
+ /* ignore */
+ }
+ return '';
+}
+
function searchAsync(client, base, options) {
return new Promise((resolve, reject) => {
const results = [];
@@ -21,7 +32,14 @@ function searchAsync(client, base, options) {
if (err) return reject(err);
res.on('searchEntry', (entry) => results.push(entry));
res.on('error', reject);
- res.on('end', () => resolve(results));
+ res.on('end', (result) => {
+ if (result && result.status !== 0) {
+ return reject(
+ new Error(`LDAP-Suche fehlgeschlagen: ${result.status}`),
+ );
+ }
+ resolve(results);
+ });
});
});
}
@@ -42,8 +60,16 @@ function unbindAsync(client) {
});
}
-function insertLog(db, row) {
- db.prepare(
+function recordLdapLastSync(dbSync) {
+ const iso = new Date().toISOString();
+ dbSync.prepare(
+ `INSERT INTO app_settings (key, value) VALUES ('ldap_last_sync_at', ?)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
+ ).run(iso);
+}
+
+function insertLog(dbSync, row) {
+ dbSync.prepare(
`INSERT INTO ldap_sync_log (id, started_at, finished_at, trigger_type, status, users_synced, error_message)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(
@@ -58,11 +84,15 @@ function insertLog(db, row) {
}
/**
- * @param {import('node:sqlite').DatabaseSync} db
+ * Entspricht der Synchronisation in Stundenerfassung (ldap-service.js):
+ * Vor-/Nachname aus LDAP, nur Einträge mit Benutzername + Vor- + Nachname,
+ * case-insensitive Abgleich, Standard-Passwort-Hash für neu angelegte Konten.
+ *
+ * @param {import('node:sqlite').DatabaseSync} dbSync
* @param {() => object} loadIntegrations
* @param {'manual' | 'automatic'} trigger
*/
-export async function performLdapSync(db, loadIntegrations, trigger) {
+export async function performLdapSync(dbSync, loadIntegrations, trigger) {
if (syncRunning) {
return { skipped: true, message: 'Synchronisation läuft bereits.' };
}
@@ -71,27 +101,37 @@ export async function performLdapSync(db, loadIntegrations, trigger) {
const startedAt = new Date().toISOString();
let usersSynced = 0;
let errorMessage = null;
+ /** @type {string[]} */
+ const rowErrors = [];
let client = null;
try {
const config = loadIntegrations().ldap || {};
+ if (!config.syncEnabled) {
+ throw new Error('LDAP-Synchronisation ist nicht aktiviert');
+ }
+
const serverUrl = String(config.serverUrl || '').trim();
const searchBase = String(config.searchBase || '').trim();
- const filter = String(
+ const filterRaw = String(
config.userSearchFilter || config.userFilter || '',
).trim();
+ const filter = filterRaw || '(objectClass=person)';
const usernameAttr = String(
config.usernameAttribute || 'sAMAccountName',
).trim();
+ const firstAttr = String(
+ config.firstNameAttribute || 'givenName',
+ ).trim();
+ const lastAttr = String(config.lastNameAttribute || 'sn').trim();
if (!serverUrl) throw new Error('LDAP-Server URL fehlt.');
if (!searchBase) throw new Error('Base DN fehlt.');
- if (!filter) throw new Error('User Search Filter fehlt.');
client = ldap.createClient({
url: serverUrl,
- timeout: 120000,
- connectTimeout: 20000,
+ timeout: 10000,
+ connectTimeout: 10000,
reconnect: false,
});
@@ -106,53 +146,90 @@ export async function performLdapSync(db, loadIntegrations, trigger) {
const entries = await searchAsync(client, searchBase, {
scope: 'sub',
filter,
- attributes: [usernameAttr],
+ attributes: [usernameAttr, firstAttr, lastAttr],
});
await unbindAsync(client);
client = null;
- db.exec('BEGIN');
- try {
- let n = 0;
- for (const entry of entries) {
- let dn;
- try {
- dn = entry.objectName.toString();
- } catch {
- continue;
- }
- const un = getAttr(entry, usernameAttr).toLowerCase().trim();
- if (!un) continue;
+ const defaultPwHash = await hashPassword('changeme123');
- const existing = db
- .prepare('SELECT id, source FROM users WHERE username = ?')
- .get(un);
- if (existing) {
- if (existing.source === 'local') continue;
- db.prepare(
- `UPDATE users SET ldap_dn = ?, updated_at = datetime('now') WHERE id = ?`,
- ).run(dn, existing.id);
- n += 1;
- } else {
- const id = randomUUID();
- db.prepare(
- `INSERT INTO users (id, username, password_hash, role, source, ldap_dn, active, updated_at)
- VALUES (?, ?, NULL, 'user', 'ldap', ?, 1, datetime('now'))`,
- ).run(id, un, dn);
- n += 1;
+ /** @type {{ username: string, firstname: string, lastname: string, dn: string }[]} */
+ const ldapUsers = [];
+ for (const entry of entries) {
+ const dn = entryDn(entry);
+ const rawUser = getAttr(entry, usernameAttr);
+ const firstname = getAttr(entry, firstAttr);
+ const lastname = getAttr(entry, lastAttr);
+ if (!rawUser || !firstname || !lastname) continue;
+ const username = String(rawUser).trim().toLowerCase();
+ if (!username) continue;
+ ldapUsers.push({ username, firstname, lastname, dn });
+ }
+
+ dbSync.exec('BEGIN');
+ try {
+ for (const u of ldapUsers) {
+ try {
+ const existing = dbSync
+ .prepare(
+ 'SELECT id, source FROM users WHERE username = ? COLLATE NOCASE',
+ )
+ .get(u.username);
+ if (existing) {
+ if (existing.source === 'ldap') {
+ dbSync
+ .prepare(
+ `UPDATE users SET firstname = ?, lastname = ?, ldap_dn = ?, updated_at = datetime('now')
+ WHERE id = ?`,
+ )
+ .run(u.firstname, u.lastname, u.dn || null, existing.id);
+ } else {
+ dbSync
+ .prepare(
+ `UPDATE users SET firstname = ?, lastname = ?, updated_at = datetime('now')
+ WHERE id = ?`,
+ )
+ .run(u.firstname, u.lastname, existing.id);
+ }
+ usersSynced += 1;
+ } else {
+ const id = randomUUID();
+ dbSync
+ .prepare(
+ `INSERT INTO users (id, username, password_hash, role, source, ldap_dn, firstname, lastname, active, updated_at)
+ VALUES (?, ?, ?, 'user', 'ldap', ?, ?, ?, 1, datetime('now'))`,
+ )
+ .run(
+ id,
+ u.username,
+ defaultPwHash,
+ u.dn || null,
+ u.firstname,
+ u.lastname,
+ );
+ usersSynced += 1;
+ }
+ } catch (e) {
+ const msg =
+ e && e.message ? String(e.message) : String(e);
+ rowErrors.push(`Fehler bei ${u.username}: ${msg}`);
}
}
- db.exec('COMMIT');
- usersSynced = n;
+ dbSync.exec('COMMIT');
+ recordLdapLastSync(dbSync);
} catch (e) {
try {
- db.exec('ROLLBACK');
+ dbSync.exec('ROLLBACK');
} catch {
/* ignore */
}
throw e;
}
+
+ if (rowErrors.length > 0) {
+ errorMessage = rowErrors.join('; ');
+ }
} catch (e) {
errorMessage = e && e.message ? String(e.message) : String(e);
if (client) {
@@ -166,44 +243,60 @@ export async function performLdapSync(db, loadIntegrations, trigger) {
} finally {
try {
const finishedAt = new Date().toISOString();
- const status = errorMessage ? 'error' : 'success';
- insertLog(db, {
+ const hasRowErr = rowErrors.length > 0;
+ const status = errorMessage || hasRowErr ? 'error' : 'success';
+ const logErr =
+ errorMessage || (hasRowErr ? rowErrors.join('; ') : null);
+ insertLog(dbSync, {
id: logId,
startedAt,
finishedAt,
triggerType: trigger,
status,
- usersSynced: errorMessage ? 0 : usersSynced,
- errorMessage,
+ usersSynced,
+ errorMessage: logErr,
});
} finally {
syncRunning = false;
}
}
- if (errorMessage) {
- return { ok: false, usersSynced: 0, error: errorMessage };
+ const hasRowErr = rowErrors.length > 0;
+ if (errorMessage && !hasRowErr) {
+ return { ok: false, usersSynced: 0, error: errorMessage, errors: [] };
}
- return { ok: true, usersSynced };
+ if (hasRowErr) {
+ return {
+ ok: false,
+ usersSynced,
+ error: errorMessage || rowErrors.join('; '),
+ errors: rowErrors,
+ };
+ }
+ return { ok: true, usersSynced, errors: [] };
}
/**
- * @param {import('node:sqlite').DatabaseSync} db
+ * @param {import('node:sqlite').DatabaseSync} dbSync
*/
-export function getSyncStatus(db) {
- const last = db
+export function getSyncStatus(dbSync) {
+ const lastSetting = dbSync
+ .prepare("SELECT value FROM app_settings WHERE key = 'ldap_last_sync_at'")
+ .get();
+ const lastLog = dbSync
.prepare(
`SELECT finished_at FROM ldap_sync_log ORDER BY finished_at DESC LIMIT 1`,
)
.get();
- const entries = db
+ const lastSyncAt = lastSetting?.value ?? lastLog?.finished_at ?? null;
+ const entries = dbSync
.prepare(
`SELECT finished_at AS finishedAt, trigger_type AS triggerType, status, users_synced AS usersSynced, error_message AS errorMessage
FROM ldap_sync_log ORDER BY finished_at DESC LIMIT 10`,
)
.all();
return {
- lastSyncAt: last?.finished_at ?? null,
+ lastSyncAt,
entries,
};
}
diff --git a/server/lib/mappers.js b/server/lib/mappers.js
index cde0844..839ab26 100644
--- a/server/lib/mappers.js
+++ b/server/lib/mappers.js
@@ -88,6 +88,8 @@ export function mapPublicUser(r) {
return {
id: r.id,
username: r.username,
+ firstName: r.firstname ?? null,
+ lastName: r.lastname ?? null,
role: r.role,
active: Boolean(r.active),
source: r.source,
diff --git a/server/routes/admin/index.js b/server/routes/admin/index.js
index 72d632e..ccdae33 100644
--- a/server/routes/admin/index.js
+++ b/server/routes/admin/index.js
@@ -19,7 +19,7 @@ export function createAdminRouter() {
admin.get('/users', (_req, res) => {
const rows = db
.prepare(
- 'SELECT id, username, role, source, active, ldap_dn, created_at, updated_at FROM users ORDER BY username ASC',
+ 'SELECT id, username, firstname, lastname, role, source, active, ldap_dn, created_at, updated_at FROM users ORDER BY username ASC',
)
.all();
res.json(rows.map(mapPublicUser));
@@ -138,7 +138,7 @@ export function createAdminRouter() {
}
if (incoming.syncIntervalMinutes != null) {
const n = Number(incoming.syncIntervalMinutes);
- incoming.syncIntervalMinutes = Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 1440;
+ incoming.syncIntervalMinutes = Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0;
}
Object.assign(cur.ldap, incoming);
if (b.ldap.userSearchFilter != null) {
diff --git a/server/routes/auth.js b/server/routes/auth.js
index 4f4c988..355d744 100644
--- a/server/routes/auth.js
+++ b/server/routes/auth.js
@@ -1,11 +1,24 @@
import { randomUUID } from 'crypto';
import { Router } from 'express';
import db from '../db.js';
+import { loadIntegrations } from '../integrations.js';
+import { authenticateLdap } from '../ldap-auth.js';
import { badRequest } from '../lib/http.js';
import { hashPassword, verifyPassword } from '../password.js';
const router = Router();
+function userPayload(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ username: row.username,
+ role: row.role,
+ firstName: row.firstname ?? null,
+ lastName: row.lastname ?? null,
+ };
+}
+
router.get('/status', (req, res) => {
const count = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
const needsBootstrap = count === 0;
@@ -16,7 +29,9 @@ router.get('/status', (req, res) => {
return res.json({ needsBootstrap: false, loggedIn: false, user: null });
}
const u = db
- .prepare('SELECT id, username, role, active FROM users WHERE id = ?')
+ .prepare(
+ 'SELECT id, username, firstname, lastname, role, active FROM users WHERE id = ?',
+ )
.get(req.session.userId);
if (!u || !u.active) {
req.session.destroy(() => {});
@@ -25,7 +40,7 @@ router.get('/status', (req, res) => {
res.json({
needsBootstrap: false,
loggedIn: true,
- user: { id: u.id, username: u.username, role: u.role },
+ user: userPayload(u),
});
});
@@ -51,25 +66,75 @@ router.post('/bootstrap', async (req, res) => {
req.session.role = 'admin';
req.session.username = un;
res.status(201).json({
- user: { id, username: un, role: 'admin' },
+ user: userPayload(
+ db.prepare(
+ 'SELECT id, username, firstname, lastname, role FROM users WHERE id = ?',
+ ).get(id),
+ ),
});
});
router.post('/login', async (req, res) => {
const { username, password } = req.body || {};
- const un = String(username || '')
- .trim()
- .toLowerCase();
- if (!un || !password) {
+ const rawUsername = String(username || '').trim();
+ const unLower = rawUsername.toLowerCase();
+ if (!rawUsername || !password) {
return badRequest(res, 'Benutzername und Passwort erforderlich.');
}
- const u = db.prepare('SELECT * FROM users WHERE username = ?').get(un);
+
+ const ldapCfg = loadIntegrations().ldap || {};
+ const useLdap =
+ Boolean(ldapCfg.syncEnabled) &&
+ String(ldapCfg.serverUrl || '').trim() !== '' &&
+ String(ldapCfg.searchBase || '').trim() !== '';
+
+ if (useLdap) {
+ const ar = await authenticateLdap(rawUsername, password, loadIntegrations);
+ if (ar.ok) {
+ const lookup = ar.canonicalUsername || rawUsername;
+ const u = db
+ .prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE')
+ .get(lookup);
+ if (!u || !u.active) {
+ return res.status(401).json({
+ message:
+ 'Benutzer nach LDAP nicht in der Anwendung gefunden. Bitte zuerst eine LDAP-Synchronisation ausführen.',
+ });
+ }
+ req.session.userId = u.id;
+ req.session.role = u.role;
+ req.session.username = u.username;
+ return res.json({
+ user: userPayload(u),
+ });
+ }
+ if (!ar.skipLdap) {
+ const u = db
+ .prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE')
+ .get(unLower);
+ if (u && u.active && u.password_hash) {
+ const pwOk = await verifyPassword(password, u.password_hash);
+ if (pwOk) {
+ req.session.userId = u.id;
+ req.session.role = u.role;
+ req.session.username = u.username;
+ return res.json({
+ user: userPayload(u),
+ });
+ }
+ }
+ return res.status(401).json({ message: 'Ungültige Zugangsdaten.' });
+ }
+ }
+
+ const u = db.prepare('SELECT * FROM users WHERE username = ?').get(unLower);
if (!u || !u.active) {
return res.status(401).json({ message: 'Ungültige Zugangsdaten.' });
}
if (!u.password_hash) {
return res.status(401).json({
- message: 'Kein lokales Passwort (LDAP). Anmeldung folgt mit Verzeichnis-Sync.',
+ message:
+ 'Kein lokales Passwort. LDAP ist nicht konfiguriert oder die Anmeldung ist nur mit Verzeichnis möglich.',
});
}
const ok = await verifyPassword(password, u.password_hash);
@@ -78,7 +143,7 @@ router.post('/login', async (req, res) => {
req.session.role = u.role;
req.session.username = u.username;
res.json({
- user: { id: u.id, username: u.username, role: u.role },
+ user: userPayload(u),
});
});