Einrichtung: Wizard um E-Mail, Kalender und REST-API erweitert

Der Assistent deckt nun alle Bereiche aus /einstellungen ab. Neue Schritte
fuer E-Mail (SMTP/IMAP) & CalDAV sowie REST-API; Indikator auf 7 Schritte.
Secrets (Passwort, Token) werden nie vorausgefuellt und beim Speeren leerer
Felder nicht ueberschrieben. Neuer Endpunkt /api/einrichtung/config mit
SSRF-Guard (Mail-Host, CalDAV-URL, Ollama-Host) und API-Token-Kollisionspruefung.
Abschluss-Checkliste trennt Pflicht- von optionalen Verbindungen.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-14 12:55:20 +02:00
co-authored by Claude
parent 1bc0188fcc
commit 2566dcc98d
2 changed files with 355 additions and 171 deletions
+58 -2
View File
@@ -1709,8 +1709,9 @@ initializeDatabase().then(async () => {
// Einrichtungsassistent: eingeblendet, wenn der Nutzer noch keine // Einrichtungsassistent: eingeblendet, wenn der Nutzer noch keine
// Bewerbung angelegt hat und den Assistenten nicht dauerhaft ausgeblendet // Bewerbung angelegt hat und den Assistenten nicht dauerhaft ausgeblendet
// hat. Der Wizard selbst führt dann durch die fehlenden Einstellungen // hat. Der Wizard führt durch alle Bereiche, die sonst unter /einstellungen
// (Persönliche Angaben, Basis-Unterlagen, Ollama-KI). // liegen: Persönliche Angaben, Basis-Unterlagen, Ollama-KI, E-Mail,
// Bewerbungskalender (CalDAV) und REST-API.
const keinDashboard = !totalCount || totalCount.count === 0; const keinDashboard = !totalCount || totalCount.count === 0;
const verstecktRow = keinDashboard const verstecktRow = keinDashboard
? await dbGet("SELECT value FROM app_state WHERE user_id = ? AND key = 'state:einrichtung_versteckt'", [U]) ? await dbGet("SELECT value FROM app_state WHERE user_id = ? AND key = 'state:einrichtung_versteckt'", [U])
@@ -1720,6 +1721,10 @@ initializeDatabase().then(async () => {
const basisRows = await dbAll('SELECT id, typ, inhalt FROM basis_dokumente WHERE user_id = ? ORDER BY id ASC', [U]); const basisRows = await dbAll('SELECT id, typ, inhalt FROM basis_dokumente WHERE user_id = ? ORDER BY id ASC', [U]);
const anschreiben = basisRows.find((d) => d.typ === 'Anschreiben'); const anschreiben = basisRows.find((d) => d.typ === 'Anschreiben');
const lebenslauf = basisRows.find((d) => d.typ === 'Lebenslauf'); const lebenslauf = basisRows.find((d) => d.typ === 'Lebenslauf');
// Aktuelle Konfiguration für die Vorbelegung — Secrets (Passwörter, Token,
// API-Schlüssel) werden bewusst nicht ans Frontend gegeben, sondern nur
// als „vorhanden"-Flag.
const cfg = config.getAll();
setupWizard = { setupWizard = {
aktiv: true, aktiv: true,
persoenlich: await loadSettings(), persoenlich: await loadSettings(),
@@ -1728,6 +1733,14 @@ initializeDatabase().then(async () => {
hasApiKey: Boolean(config.get('OLLAMA_API_KEY')), hasApiKey: Boolean(config.get('OLLAMA_API_KEY')),
ollamaModel: config.get('OLLAMA_MODEL'), ollamaModel: config.get('OLLAMA_MODEL'),
ollamaHost: config.get('OLLAMA_HOST'), ollamaHost: config.get('OLLAMA_HOST'),
cfg: {
MAIL_HOST: cfg.MAIL_HOST, MAIL_SMTP_PORT: cfg.MAIL_SMTP_PORT, MAIL_IMAP_PORT: cfg.MAIL_IMAP_PORT,
MAIL_USER: cfg.MAIL_USER, MAIL_FROM_NAME: cfg.MAIL_FROM_NAME, MAIL_FROM: cfg.MAIL_FROM,
MAIL_IMAP_MAILBOX: cfg.MAIL_IMAP_MAILBOX, MAIL_POLL_MS: cfg.MAIL_POLL_MS,
hasMailPassword: Boolean(cfg.MAIL_PASSWORD),
CALDAV_URL: cfg.CALDAV_URL, CALDAV_ALARM_MIN: cfg.CALDAV_ALARM_MIN, CALDAV_POLL_MS: cfg.CALDAV_POLL_MS,
hasApiToken: Boolean(cfg.API_TOKEN),
},
}; };
} }
@@ -2063,6 +2076,49 @@ initializeDatabase().then(async () => {
} }
}); });
// Generisches Speichern für die weiteren Wizard-Schritte (E-Mail, CalDAV,
// REST-API). Nimmt nur Schlüssel aus config.DEFAULTS entgegen und schreibt
// sie gezielt via setForUser — ungenannte Keys bleiben unangetastet. Hosts
// werden gegen SSRF geprüft, der API-Token auf Kollision mit anderem Benutzer.
app.post('/api/einrichtung/config', async (req, res) => {
try {
const u = uid();
const erlaubt = new Set(Object.keys(config.DEFAULTS));
const werte = {};
Object.keys(req.body || {}).forEach(function (k) {
if (erlaubt.has(k)) werte[k] = String(req.body[k] == null ? '' : req.body[k]).trim();
});
// SSRF-Guard für externe Hosts, die der Server anruft.
if (werte.OLLAMA_HOST) {
try { await assertSafeUrl(werte.OLLAMA_HOST); }
catch (vErr) { return res.status(400).json({ error: 'Ollama-Host abgelehnt: ' + (vErr.message || 'ungültig') }); }
}
if (werte.CALDAV_URL) {
try { await assertSafeUrl(werte.CALDAV_URL); }
catch (vErr) { return res.status(400).json({ error: 'Kalender-URL abgelehnt: ' + (vErr.message || 'ungültig') }); }
}
if (werte.MAIL_HOST) {
try { await assertSafeHost(werte.MAIL_HOST); }
catch (vErr) { return res.status(400).json({ error: 'Mail-Host abgelehnt: ' + (vErr.message || 'ungültig') }); }
}
// API-Token identifiziert den Benutzer gegenüber /api/v1 — Kollision würde
// beide Nutzer aussperren, daher schon hier ablehnen.
if (werte.API_TOKEN) {
const fremd = await dbGet(
`SELECT u.username FROM app_state a JOIN users u ON u.id = a.user_id
WHERE a.key = ? AND a.value = ? AND a.user_id != ?`,
[config.PREFIX + 'API_TOKEN', werte.API_TOKEN, u]
);
if (fremd) return res.status(409).json({ error: 'API-Token bereits von anderem Benutzer belegt. Bitte „Neu generieren".' });
}
for (const k of Object.keys(werte)) await config.setForUser(u, k, werte[k]);
res.json({ success: true });
} catch (error) {
console.error('Einrichtung/config:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Wizard dauerhaft ausblenden (app_state-Flag), damit er nicht bei jedem // Wizard dauerhaft ausblenden (app_state-Flag), damit er nicht bei jedem
// Besuch der leeren Startseite wieder erscheint. // Besuch der leeren Startseite wieder erscheint.
app.post('/api/einrichtung/verstecken', async (req, res) => { app.post('/api/einrichtung/verstecken', async (req, res) => {
+291 -163
View File
@@ -1,11 +1,14 @@
<% <%
// Einrichtungsassistent für Nutzer, die noch keine Bewerbung angelegt haben. // Einrichtungsassistent für Nutzer, die noch keine Bewerbung angelegt haben.
// Server liefert `setupWizard` mit: aktiv, persoenlich (row), anschreiben/lebenslauf // Server liefert `setupWizard` mit: aktiv, persoenlich (row),
// ({id,inhalt}), hasApiKey, ollamaModel, ollamaHost. Das Partial rendert nur den // anschreiben/lebenslauf ({id,inhalt}), hasApiKey, ollamaModel, ollamaHost,
// Wizard; das umgebende index.ejs blendet gleichzeitig das Dashboard aus. // cfg (nicht-geheime Mail-/CalDAV-Werte + hasMailPassword + hasApiToken).
// Das Partial rendert nur den Wizard; das umgebende index.ejs blendet
// gleichzeitig das Dashboard aus (Markup bleibt im DOM für main.js).
var p = setupWizard.persoenlich || {}; var p = setupWizard.persoenlich || {};
var anschreibenInhalt = setupWizard.anschreiben ? setupWizard.anschreiben.inhalt : ''; var anschreibenInhalt = setupWizard.anschreiben ? setupWizard.anschreiben.inhalt : '';
var lebenslaufInhalt = setupWizard.lebenslauf ? setupWizard.lebenslauf.inhalt : ''; var lebenslaufInhalt = setupWizard.lebenslauf ? setupWizard.lebenslauf.inhalt : '';
var c = setupWizard.cfg || {};
%> %>
<style> <style>
/* Fortschrittsanzeige: Verbindungslinie zwischen den Schritt-Punkten. */ /* Fortschrittsanzeige: Verbindungslinie zwischen den Schritt-Punkten. */
@@ -13,50 +16,44 @@
.einr-steps::before { .einr-steps::before {
content: ""; content: "";
position: absolute; position: absolute;
top: 50%; top: 14px; /* Mitte der 28px-Punkte */
left: 0; left: 6%;
right: 0; right: 6%;
height: 2px; height: 2px;
background-color: rgba(255, 255, 255, 0.25); background-color: rgba(255, 255, 255, 0.25);
transform: translateY(-1px);
} }
.einr-step-dot { .einr-step-dot {
position: relative; position: relative;
z-index: 1; z-index: 1;
transition: background-color 0.2s ease, transform 0.2s ease; transition: background-color 0.2s ease, transform 0.2s ease;
} }
.einr-step.is-active .einr-step-dot { .einr-step.is-active .einr-step-dot { transform: scale(1.12); }
transform: scale(1.12);
}
/* Sanftes Einblenden der Schrittinhalte. */ /* Sanftes Einblenden der Schrittinhalte. */
.einr-panel { animation: einrFade 0.28s ease; } .einr-panel { animation: einrFade 0.28s ease; }
@keyframes einrFade { @keyframes einrFade {
from { opacity: 0; transform: translateY(6px); } from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) { .einr-panel { animation: none; } }
.einr-panel { animation: none; }
}
</style> </style>
<section id="einrichtung" class="mb-10"> <section id="einrichtung" class="mb-10">
<div class="mx-auto max-w-3xl overflow-hidden rounded-2xl border border-gray-200/80 bg-white shadow-xl shadow-gray-900/5 dark:border-white/10 dark:bg-gray-800"> <div class="mx-auto max-w-3xl overflow-hidden rounded-2xl border border-gray-200/80 bg-white shadow-xl shadow-gray-900/5 dark:border-white/10 dark:bg-gray-800">
<!-- Kopfbereich mit Verlaufsanzeige --> <!-- Kopfbereich mit Verlaufsanzeige -->
<div class="relative bg-gradient-to-br from-sky-500 via-blue-600 to-indigo-600 px-6 py-7 text-white sm:px-8"> <div class="relative bg-gradient-to-br from-sky-500 via-blue-600 to-indigo-600 px-5 py-7 text-white sm:px-8">
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-white/15 ring-1 ring-white/25 backdrop-blur"> <span class="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-white/15 ring-1 ring-white/25 backdrop-blur">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.293-.24.438-.613.43-.991a7.723 7.723 0 010-.255c.008-.379-.137-.75-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.28z"></path> <path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496 3.06c.318.392.74.665 1.215.82a3.51 3.51 0 003.99-1.51l1.75-2.93a1.5 1.5 0 00-2.57-1.55l-1.75 2.93a.75.75 0 01-1.05.23l-.4-.27m-3.24-2.29l-2.83-3.5m0 0A3.291 3.291 0 001.87 7.28l.8 1.2m4.4 3.69l-3.6-5.4m1.55 6.6L4 7.6"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg> </svg>
</span> </span>
<div> <div>
<h1 class="text-xl font-semibold leading-tight sm:text-2xl">NextJobs einrichten</h1> <h1 class="text-xl font-semibold leading-tight sm:text-2xl">NextJobs einrichten</h1>
<p class="mt-1 max-w-md text-sm text-white/80"> <p class="mt-1 max-w-md text-sm text-white/80">
In wenigen Schritten bist du startklar für deine erste Bewerbung. In wenigen Schritten bist du startklar für deine erste Bewerbung.
Du kannst jeden Schritt überspringen und später nachholen. Jeder Schritt lässt sich überspringen und später nachholen.
</p> </p>
</div> </div>
</div> </div>
@@ -66,56 +63,36 @@
</button> </button>
</div> </div>
<!-- Schrittanzeige --> <!-- Schrittanzeige (7 Schritte; Labels nur auf >= sm) -->
<ol id="einrSteps" class="einr-steps mt-7 flex items-center justify-between gap-1"> <ol id="einrSteps" class="einr-steps mt-7 flex items-start justify-between gap-1">
<li class="einr-step flex flex-1 flex-col items-center gap-1.5 text-center" data-step="1"> <% var stepLabels = ['Willkommen','Profil','Vorlagen','KI','E-Mail','API','Abschluss']; %>
<span class="einr-step-dot grid h-8 w-8 place-items-center rounded-full bg-white/20 text-xs font-semibold ring-1 ring-white/30">1</span> <% for (var si = 1; si <= 7; si++) { %>
<span class="text-[11px] font-medium text-white/80">Willkommen</span> <li class="einr-step flex flex-1 flex-col items-center gap-1.5 text-center" data-step="<%= si %>">
</li> <span class="einr-step-dot grid h-7 w-7 place-items-center rounded-full bg-white/20 text-xs font-semibold ring-1 ring-white/30"><%= si %></span>
<li class="einr-step flex flex-1 flex-col items-center gap-1.5 text-center" data-step="2"> <span class="hidden text-[11px] font-medium text-white/80 sm:block"><%= stepLabels[si-1] %></span>
<span class="einr-step-dot grid h-8 w-8 place-items-center rounded-full bg-white/20 text-xs font-semibold ring-1 ring-white/30">2</span>
<span class="text-[11px] font-medium text-white/80">Profil</span>
</li>
<li class="einr-step flex flex-1 flex-col items-center gap-1.5 text-center" data-step="3">
<span class="einr-step-dot grid h-8 w-8 place-items-center rounded-full bg-white/20 text-xs font-semibold ring-1 ring-white/30">3</span>
<span class="text-[11px] font-medium text-white/80">Vorlagen</span>
</li>
<li class="einr-step flex flex-1 flex-col items-center gap-1.5 text-center" data-step="4">
<span class="einr-step-dot grid h-8 w-8 place-items-center rounded-full bg-white/20 text-xs font-semibold ring-1 ring-white/30">4</span>
<span class="text-[11px] font-medium text-white/80">KI</span>
</li>
<li class="einr-step flex flex-1 flex-col items-center gap-1.5 text-center" data-step="5">
<span class="einr-step-dot grid h-8 w-8 place-items-center rounded-full bg-white/20 text-xs font-semibold ring-1 ring-white/30">5</span>
<span class="text-[11px] font-medium text-white/80">Abschluss</span>
</li> </li>
<% } %>
</ol> </ol>
</div> </div>
<!-- Formular / Schrittinhalte --> <!-- Formular / Schrittinhalte -->
<form id="einrForm" class="px-6 py-6 sm:px-8" autocomplete="off" novalidate> <form id="einrForm" class="px-5 py-6 sm:px-8" autocomplete="off" novalidate>
<!-- Schritt 1: Willkommen --> <!-- Schritt 1: Willkommen -->
<div class="einr-panel" data-panel="1"> <div class="einr-panel" data-panel="1">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Herzlich willkommen!</h2> <h2 class="text-lg font-semibold text-gray-800 dark:text-white">Herzlich willkommen!</h2>
<p class="mt-2 text-sm leading-relaxed text-gray-600 dark:text-gray-300"> <p class="mt-2 text-sm leading-relaxed text-gray-600 dark:text-gray-300">
NextJobs begleitet dich vom Stellenimport bis zur fertigen Bewerbung — mit NextJobs begleitet dich vom Stellenimport bis zur fertigen Bewerbung — mit
automatisch zugeschnittenen Anschreiben und Lebenslauf. Damit das klappt, automatisch zugeschnittenen Anschreiben und Lebenslauf, E-Mail-Posteingang
hinterlegen wir zuerst deine Kontaktdaten, deine Basis-Unterlagen und den und Bewerbungskalender. Damit das klappt, hinterlegen wir zuerst die nötigen
KI-Zugang. Das dauert etwa drei Minuten. Daten. Das dauert etwa fünf Minuten.
</p> </p>
<ul class="mt-5 space-y-2.5 text-sm text-gray-600 dark:text-gray-300"> <ul class="mt-5 space-y-2.5 text-sm text-gray-600 dark:text-gray-300">
<li class="flex items-start gap-2.5"> <li class="flex items-start gap-2.5"><svg class="mt-0.5 h-5 w-5 shrink-0 text-blue-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg><span><strong class="font-medium text-gray-800 dark:text-white">Persönliche Angaben</strong> — fließen in jede Bewerbung ein.</span></li>
<svg class="mt-0.5 h-5 w-5 shrink-0 text-blue-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> <li class="flex items-start gap-2.5"><svg class="mt-0.5 h-5 w-5 shrink-0 text-blue-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg><span><strong class="font-medium text-gray-800 dark:text-white">Basis-Unterlagen</strong> — dein Standard-Anschreiben &amp; Lebenslauf als Text.</span></li>
<span><strong class="font-medium text-gray-800 dark:text-white">Persönliche Angaben</strong> — fließen in jede Bewerbung ein.</span> <li class="flex items-start gap-2.5"><svg class="mt-0.5 h-5 w-5 shrink-0 text-blue-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg><span><strong class="font-medium text-gray-800 dark:text-white">Ollama-KI</strong> — erzeugt daraus die passenden Unterlagen.</span></li>
</li> <li class="flex items-start gap-2.5"><svg class="mt-0.5 h-5 w-5 shrink-0 text-blue-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg><span><strong class="font-medium text-gray-800 dark:text-white">E-Mail &amp; Kalender</strong> — Posteingang versenden und Termine synchronisieren.</span></li>
<li class="flex items-start gap-2.5"> <li class="flex items-start gap-2.5"><svg class="mt-0.5 h-5 w-5 shrink-0 text-blue-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg><span><strong class="font-medium text-gray-800 dark:text-white">REST-API</strong> — optional, für Drittanbietersoftware.</span></li>
<svg class="mt-0.5 h-5 w-5 shrink-0 text-blue-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span><strong class="font-medium text-gray-800 dark:text-white">Basis-Unterlagen</strong> — dein Standard-Anschreiben & Lebenslauf als Text.</span>
</li>
<li class="flex items-start gap-2.5">
<svg class="mt-0.5 h-5 w-5 shrink-0 text-blue-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span><strong class="font-medium text-gray-800 dark:text-white">Ollama-KI</strong> — erzeugt daraus später die passenden Unterlagen.</span>
</li>
</ul> </ul>
</div> </div>
@@ -184,8 +161,7 @@
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Basis-Unterlagen</h2> <h2 class="text-lg font-semibold text-gray-800 dark:text-white">Basis-Unterlagen</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Füge dein Standard-Anschreiben und deinen Lebenslauf als Text ein. Die KI Füge dein Standard-Anschreiben und deinen Lebenslauf als Text ein. Die KI
nutzt sie als Faktengrundlage und erfindet nichts hinzu. Du kannst auch nutzt sie als Faktengrundlage und erfindet nichts hinzu.
schon vorhandene Texte einfach hereinkopieren.
</p> </p>
<div class="mt-5 space-y-5"> <div class="mt-5 space-y-5">
<div> <div>
@@ -201,8 +177,7 @@
placeholder="Persönliche Daten, Berufserfahrung, Ausbildung, Kenntnisse …"><%= lebenslaufInhalt %></textarea> placeholder="Persönliche Daten, Berufserfahrung, Ausbildung, Kenntnisse …"><%= lebenslaufInhalt %></textarea>
</div> </div>
<p class="text-xs text-gray-400 dark:text-gray-500"> <p class="text-xs text-gray-400 dark:text-gray-500">
Tipp: Ausführliche Vorlagen lassen sich später unter „Vorlagen“ verfeinern — Tipp: Ausführliche Vorlagen lassen sich später unter „Vorlagen“ verfeinern — inkl. Design und Foto.
inkl. Design, Foto und weiteren Doktypen.
</p> </p>
</div> </div>
</div> </div>
@@ -212,8 +187,8 @@
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Ollama-KI einrichten</h2> <h2 class="text-lg font-semibold text-gray-800 dark:text-white">Ollama-KI einrichten</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Über die Ollama Cloud erzeugt NextJobs automatisch zugeschnittene Über die Ollama Cloud erzeugt NextJobs automatisch zugeschnittene
Bewerbungsunterlagen. Ohne API-Schlüssel bleibt dieser Schritt deaktiviert — Bewerbungsunterlagen. Ohne API-Schlüssel bleibt die KI deaktiviert —
du kannst ihn auch später unter „Einstellungen“ nachholen. später unter „Einstellungen“ nachholbar.
</p> </p>
<div class="mt-5 space-y-4"> <div class="mt-5 space-y-4">
<div> <div>
@@ -222,21 +197,14 @@
<input type="password" id="einr-apikey" name="OLLAMA_API_KEY" <input type="password" id="einr-apikey" name="OLLAMA_API_KEY"
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 pr-10 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white" class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 pr-10 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="ol-…" autocomplete="off"> placeholder="ol-…" autocomplete="off">
<button type="button" id="einr-toggle-key" <button type="button" class="einr-reveal absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" data-target="einr-apikey" title="Wert anzeigen/verbergen" tabindex="-1">
class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
title="Wert anzeigen/verbergen" tabindex="-1">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
</button> </button>
</div> </div>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500"> <p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
Schlüssel holen unter Schlüssel holen unter
<a href="https://ollama.com/settings/keys" target="_blank" rel="noopener" class="text-blue-600 hover:underline dark:text-sky-400">ollama.com/settings/keys</a>. <a href="https://ollama.com/settings/keys" target="_blank" rel="noopener" class="text-blue-600 hover:underline dark:text-sky-400">ollama.com/settings/keys</a>.
<% if (setupWizard.hasApiKey) { %> <% if (setupWizard.hasApiKey) { %><span class="ml-1 text-green-600 dark:text-green-400">· bereits hinterlegt</span><% } %>
<span class="ml-1 text-green-600 dark:text-green-400">· bereits hinterlegt</span>
<% } %>
</p> </p>
</div> </div>
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
@@ -256,30 +224,159 @@
</div> </div>
</div> </div>
<!-- Schritt 5: Abschluss --> <!-- Schritt 5: E-Mail & Kalender -->
<div class="einr-panel hidden" data-panel="5"> <div class="einr-panel hidden" data-panel="5">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">E-Mail &amp; Kalender</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
E-Mail-Empfang (IMAP) und -Versand (SMTP) laufen über dein eigenes Postfach.
Der Bewerbungskalender nutzt CalDAV und teilt sich Login &amp; Passwort mit
der E-Mail. Beides ist optional — ohne Angabe bleiben Postfach und
Kalender-Sync deaktiviert.
</p>
<!-- E-Mail -->
<div class="mt-5">
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">E-Mail (SMTP / IMAP)</h3>
<div class="mt-3 grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<label for="einr-mail-host" class="block text-xs font-medium text-gray-500 dark:text-gray-400">SMTP/IMAP Host</label>
<input type="text" id="einr-mail-host" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="mail.example.com" value="<%= c.MAIL_HOST || '' %>">
</div>
<div>
<label for="einr-mail-smtp-port" class="block text-xs font-medium text-gray-500 dark:text-gray-400">SMTP-Port</label>
<input type="text" id="einr-mail-smtp-port" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="<%= c.MAIL_SMTP_PORT || '587' %>">
<p class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">587 = STARTTLS, 465 = implicit TLS</p>
</div>
<div>
<label for="einr-mail-imap-port" class="block text-xs font-medium text-gray-500 dark:text-gray-400">IMAP-Port</label>
<input type="text" id="einr-mail-imap-port" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="<%= c.MAIL_IMAP_PORT || '993' %>">
</div>
<div>
<label for="einr-mail-user" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Benutzername (Login)</label>
<input type="text" id="einr-mail-user" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="name@example.com" value="<%= c.MAIL_USER || '' %>">
<p class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">Auch CalDAV-Login</p>
</div>
<div>
<label for="einr-mail-password" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Passwort</label>
<div class="relative mt-1">
<input type="password" id="einr-mail-password"
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 pr-10 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="<%= c.hasMailPassword ? 'vorhanden — leer lassen, um nichts zu ändern' : 'Passwort' %>" autocomplete="off">
<button type="button" class="einr-reveal absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" data-target="einr-mail-password" title="Wert anzeigen/verbergen" tabindex="-1">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</button>
</div>
</div>
<div>
<label for="einr-mail-fromname" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Absendername</label>
<input type="text" id="einr-mail-fromname" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Max Mustermann" value="<%= c.MAIL_FROM_NAME || '' %>">
</div>
<div>
<label for="einr-mail-from" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Absenderadresse</label>
<input type="text" id="einr-mail-from" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="leer = Benutzername" value="<%= c.MAIL_FROM || '' %>">
</div>
<div>
<label for="einr-mail-mailbox" class="block text-xs font-medium text-gray-500 dark:text-gray-400">IMAP-Postfach</label>
<input type="text" id="einr-mail-mailbox" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="<%= c.MAIL_IMAP_MAILBOX || 'INBOX' %>">
</div>
<div>
<label for="einr-mail-poll" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Abrufintervall (ms)</label>
<input type="text" id="einr-mail-poll" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="<%= c.MAIL_POLL_MS || '180000' %>">
<p class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">greift nach Neustart</p>
</div>
</div>
</div>
<!-- CalDAV -->
<div class="mt-7">
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Bewerbungskalender (CalDAV)</h3>
<div class="mt-3 grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<label for="einr-caldav-url" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Kalender-URL</label>
<input type="text" id="einr-caldav-url" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="https://mail.example.com/SOGo/dav/name@…/Calendar/XXXX/" value="<%= c.CALDAV_URL || '' %>">
<p class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">Voll-URL der Sammlung, mit abschließendem /. Anmeldung über Benutzername/Passwort oben.</p>
</div>
<div>
<label for="einr-caldav-alarm" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Erinnerung (Min. vor Termin)</label>
<input type="text" id="einr-caldav-alarm" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="<%= c.CALDAV_ALARM_MIN || '60' %>">
</div>
<div>
<label for="einr-caldav-poll" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Sync-Intervall (ms)</label>
<input type="text" id="einr-caldav-poll" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="<%= c.CALDAV_POLL_MS || '300000' %>">
<p class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">greift nach Neustart</p>
</div>
</div>
</div>
</div>
<!-- Schritt 6: REST-API -->
<div class="einr-panel hidden" data-panel="6">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">REST-API (optional)</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Ist ein Token gesetzt, ist <code class="rounded bg-gray-100 px-1 dark:bg-gray-700">/api/v1</code>
für dich aktiv und erwartet den Wert im Header
<code class="rounded bg-gray-100 px-1 dark:bg-gray-700">X-API-Key</code>.
Der Token identifiziert dein Konto — Anfragen sehen und ändern nur deine
eigenen Daten. Swagger unter <a href="/swagger" class="text-blue-600 hover:underline dark:text-sky-400">/swagger</a>.
</p>
<div class="mt-5">
<label for="einr-api-token" class="block text-sm font-medium text-gray-700 dark:text-gray-300">API-Token</label>
<div class="relative mt-1">
<input type="password" id="einr-api-token"
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 pr-10 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="<%= c.hasApiToken ? 'vorhanden — leer lassen, um nichts zu ändern' : 'Token erzeugen oder eintragen' %>" autocomplete="off">
<button type="button" class="einr-reveal absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" data-target="einr-api-token" title="Wert anzeigen/verbergen" tabindex="-1">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</button>
</div>
<div class="mt-2 flex flex-wrap items-center gap-2">
<button type="button" id="einr-token-gen"
class="inline-flex items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 transition hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
Neu generieren
</button>
<button type="button" id="einr-token-copy"
class="inline-flex items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 transition hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
Kopieren
</button>
<span id="einr-token-hint" class="hidden text-xs text-amber-600 dark:text-amber-400"></span>
<% if (c.hasApiToken) { %><span class="text-xs text-green-600 dark:text-green-400">· bereits hinterlegt</span><% } %>
</div>
<p class="mt-2 text-[11px] text-gray-400 dark:text-gray-500">Leer lassen = vorhandenen Token nicht anfassen. Ein neuer Token macht den alten sofort ungültig.</p>
</div>
</div>
<!-- Schritt 7: Abschluss -->
<div class="einr-panel hidden" data-panel="7">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Fast geschafft!</h2> <h2 class="text-lg font-semibold text-gray-800 dark:text-white">Fast geschafft!</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Hier ist dein Stand. Fehlende Punkte kannst du jederzeit unter Hier ist dein Stand. Fehlende Punkte lassen sich jederzeit unter
„Vorlagen“ oder „Einstellungen“ nachholen. „Vorlagen“ oder „Einstellungen“ nachholen.
</p> </p>
<ul id="einrStatus" class="mt-5 divide-y divide-gray-100 dark:divide-gray-700/60 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700"> <ul class="mt-5 divide-y divide-gray-100 dark:divide-gray-700/60 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<li class="flex items-center gap-3 px-4 py-3" data-check="profil"> <li class="flex items-center gap-3 px-4 py-3" data-check="profil"><span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span><span class="text-sm text-gray-700 dark:text-gray-200">Persönliche Angaben</span></li>
<span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span> <li class="flex items-center gap-3 px-4 py-3" data-check="anschreiben"><span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span><span class="text-sm text-gray-700 dark:text-gray-200">Standard-Anschreiben hinterlegt</span></li>
<span class="text-sm text-gray-700 dark:text-gray-200">Persönliche Angaben</span> <li class="flex items-center gap-3 px-4 py-3" data-check="lebenslauf"><span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span><span class="text-sm text-gray-700 dark:text-gray-200">Lebenslauf hinterlegt</span></li>
</li> <li class="flex items-center gap-3 px-4 py-3" data-check="ki"><span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span><span class="text-sm text-gray-700 dark:text-gray-200">Ollama-KI aktiv</span></li>
<li class="flex items-center gap-3 px-4 py-3" data-check="anschreiben"> </ul>
<span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span>
<span class="text-sm text-gray-700 dark:text-gray-200">Standard-Anschreiben hinterlegt</span> <p class="mt-5 text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Weitere Verbindungen (optional)</p>
</li> <ul class="mt-2 divide-y divide-gray-100 dark:divide-gray-700/60 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<li class="flex items-center gap-3 px-4 py-3" data-check="lebenslauf"> <li class="flex items-center gap-3 px-4 py-3" data-opt="mail"><span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span><span class="text-sm text-gray-700 dark:text-gray-200">E-Mail (SMTP/IMAP)</span></li>
<span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span> <li class="flex items-center gap-3 px-4 py-3" data-opt="kalender"><span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span><span class="text-sm text-gray-700 dark:text-gray-200">Bewerbungskalender (CalDAV)</span></li>
<span class="text-sm text-gray-700 dark:text-gray-200">Lebenslauf hinterlegt</span> <li class="flex items-center gap-3 px-4 py-3" data-opt="api"><span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span><span class="text-sm text-gray-700 dark:text-gray-200">REST-API-Token</span></li>
</li>
<li class="flex items-center gap-3 px-4 py-3" data-check="ki">
<span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span>
<span class="text-sm text-gray-700 dark:text-gray-200">Ollama-KI aktiv</span>
</li>
</ul> </ul>
<div class="mt-5 rounded-lg bg-blue-50 p-4 text-sm text-blue-800 dark:bg-blue-500/10 dark:text-blue-200"> <div class="mt-5 rounded-lg bg-blue-50 p-4 text-sm text-blue-800 dark:bg-blue-500/10 dark:text-blue-200">
@@ -330,14 +427,21 @@
var form = document.getElementById('einrForm'); var form = document.getElementById('einrForm');
if (!form) return; if (!form) return;
var MAX = 7;
var step = 1; var step = 1;
var MAX = 5;
var panels = {}; var panels = {};
Array.prototype.slice.call(form.querySelectorAll('.einr-panel')).forEach(function (el) { Array.prototype.slice.call(form.querySelectorAll('.einr-panel')).forEach(function (el) {
panels[el.getAttribute('data-panel')] = el; panels[el.getAttribute('data-panel')] = el;
}); });
var stepEls = Array.prototype.slice.call(document.querySelectorAll('#einrSteps .einr-step')); var stepEls = Array.prototype.slice.call(document.querySelectorAll('#einrSteps .einr-step'));
// Vorhandene Secrets kennen wir nur als Flag — der Wert steht nie im Formular.
var VORHANDEN = {
apiKey: <%= setupWizard.hasApiKey ? 'true' : 'false' %>,
mailPassword: <%= (c.hasMailPassword) ? 'true' : 'false' %>,
apiToken: <%= (c.hasApiToken) ? 'true' : 'false' %>
};
var btnZurueck = document.getElementById('einrZurueck'); var btnZurueck = document.getElementById('einrZurueck');
var btnUeberspringen = document.getElementById('einrUeberspringen'); var btnUeberspringen = document.getElementById('einrUeberspringen');
var btnWeiter = document.getElementById('einrWeiter'); var btnWeiter = document.getElementById('einrWeiter');
@@ -348,23 +452,19 @@
var btnAusblenden = document.getElementById('einrAusblenden'); var btnAusblenden = document.getElementById('einrAusblenden');
var fehler = document.getElementById('einrFehler'); var fehler = document.getElementById('einrFehler');
var labels = { 1: 'Los geht\'s', 2: 'Weiter', 3: 'Weiter', 4: 'Weiter', 5: 'Abschließen' }; var labels = { 1: 'Los geht\'s', 2: 'Weiter', 3: 'Weiter', 4: 'Weiter', 5: 'Weiter', 6: 'Weiter', 7: 'Abschließen' };
function showFehler(msg) {
fehler.textContent = msg || '';
fehler.classList.toggle('hidden', !msg);
}
function v(sel) { var el = form.querySelector(sel); return el ? el.value : ''; }
function showFehler(msg) { fehler.textContent = msg || ''; fehler.classList.toggle('hidden', !msg); }
function setBusy(busy) { function setBusy(busy) {
btnWeiter.disabled = busy; btnWeiter.disabled = busy;
spinner.classList.toggle('hidden', !busy); spinner.classList.toggle('hidden', !busy);
iconWeiter.classList.toggle('hidden', busy); iconWeiter.classList.toggle('hidden', busy);
} }
function check(r) { if (!r.ok || !r.json.success) throw new Error(r.json.error || 'Speichern fehlgeschlagen'); }
function render() { function render() {
Object.keys(panels).forEach(function (n) { Object.keys(panels).forEach(function (n) { panels[n].classList.toggle('hidden', String(n) !== String(step)); });
panels[n].classList.toggle('hidden', String(n) !== String(step));
});
stepEls.forEach(function (el) { stepEls.forEach(function (el) {
var n = Number(el.getAttribute('data-step')); var n = Number(el.getAttribute('data-step'));
var dot = el.querySelector('.einr-step-dot'); var dot = el.querySelector('.einr-step-dot');
@@ -372,7 +472,7 @@
if (n < step) { if (n < step) {
dot.style.backgroundColor = 'rgba(255,255,255,0.92)'; dot.style.backgroundColor = 'rgba(255,255,255,0.92)';
dot.style.color = '#1d4ed8'; dot.style.color = '#1d4ed8';
dot.innerHTML = '<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>'; dot.innerHTML = '<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>';
} else if (n === step) { } else if (n === step) {
dot.style.backgroundColor = '#ffffff'; dot.style.backgroundColor = '#ffffff';
dot.style.color = '#1d4ed8'; dot.style.color = '#1d4ed8';
@@ -383,7 +483,6 @@
dot.textContent = String(n); dot.textContent = String(n);
} }
}); });
btnZurueck.classList.toggle('hidden', step === 1); btnZurueck.classList.toggle('hidden', step === 1);
btnUeberspringen.classList.toggle('hidden', step === 1 || step === MAX); btnUeberspringen.classList.toggle('hidden', step === 1 || step === MAX);
btnDirekt.classList.toggle('hidden', step !== 1 && step !== MAX); btnDirekt.classList.toggle('hidden', step !== 1 && step !== MAX);
@@ -392,35 +491,43 @@
function gehZu(n) { function gehZu(n) {
step = Math.max(1, Math.min(MAX, n)); step = Math.max(1, Math.min(MAX, n));
if (step === MAX) aktualiereStatus(); if (step === MAX) { aktualiereStatus(); }
render(); render();
// Oberkante sichtbar halten, damit der nächste Schritt nicht unter dem
// sticky Header verschwindet.
var sec = document.getElementById('einrichtung'); var sec = document.getElementById('einrichtung');
if (sec) sec.scrollIntoView({ behavior: 'smooth', block: 'start' }); if (sec) sec.scrollIntoView({ behavior: 'smooth', block: 'start' });
} }
// Abschluss-Checkliste live aus den Formularwerten berechnen. // ico für Pflicht-Checkliste: grün = erledigt, orange = offen.
function aktualiereStatus() { function setzeCheck(name, ok) {
var vals = { var li = form.querySelector('[data-check="' + name + '"]');
profil: !!(form.querySelector('#einr-name') || {}).value,
anschreiben: !!(form.querySelector('#einr-anschreiben') || {}).value,
lebenslauf: !!(form.querySelector('#einr-lebenslauf') || {}).value,
ki: !!(form.querySelector('#einr-apikey') || {}).value
};
if (!vals.ki) vals.ki = <%= setupWizard.hasApiKey ? 'true' : 'false' %>;
Object.keys(vals).forEach(function (k) {
var li = form.querySelector('[data-check="' + k + '"]');
if (!li) return; if (!li) return;
var ico = li.querySelector('.einr-ico'); var ico = li.querySelector('.einr-ico');
var ok = !!vals[k];
ico.style.backgroundColor = ok ? 'rgba(16,185,129,0.12)' : 'rgba(245,158,11,0.12)'; ico.style.backgroundColor = ok ? 'rgba(16,185,129,0.12)' : 'rgba(245,158,11,0.12)';
ico.style.color = ok ? '#059669' : '#d97706'; ico.style.color = ok ? '#059669' : '#d97706';
ico.innerHTML = ok ico.innerHTML = ok
? '<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>' ? '<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>'
: '<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01"/></svg>'; : '<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01"/></svg>';
li.style.color = ok ? '' : ''; }
}); // ico für optionale Verbindungen: grün = eingerichtet, neutral = optional/offen.
function setzeOpt(name, ok) {
var li = form.querySelector('[data-opt="' + name + '"]');
if (!li) return;
var ico = li.querySelector('.einr-ico');
ico.style.backgroundColor = ok ? 'rgba(16,185,129,0.12)' : 'rgba(148,163,184,0.15)';
ico.style.color = ok ? '#059669' : '#94a3b8';
ico.innerHTML = ok
? '<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>'
: '<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14"/></svg>';
}
function aktualiereStatus() {
setzeCheck('profil', !!v('#einr-name').trim());
setzeCheck('anschreiben', !!v('#einr-anschreiben').trim());
setzeCheck('lebenslauf', !!v('#einr-lebenslauf').trim());
setzeCheck('ki', !!v('#einr-apikey').trim() || VORHANDEN.apiKey);
setzeOpt('mail', !!v('#einr-mail-host').trim());
setzeOpt('kalender', !!v('#einr-caldav-url').trim());
setzeOpt('api', !!v('#einr-api-token').trim() || VORHANDEN.apiToken);
} }
function post(url, data) { function post(url, data) {
@@ -433,40 +540,44 @@
// Speichert den Inhalt des aktuellen Schritts vor dem Weiterspringen. // Speichert den Inhalt des aktuellen Schritts vor dem Weiterspringen.
function speichern(stepNr) { function speichern(stepNr) {
if (stepNr === 2) { if (stepNr === 2) {
if (!form.querySelector('#einr-name').value.trim()) { if (!v('#einr-name').trim()) {
showFehler('Bitte gib mindestens deinen Namen ein — das ist das einzige Pflichtfeld.'); showFehler('Bitte gib mindestens deinen Namen ein — das ist das einzige Pflichtfeld.');
return Promise.reject(); return Promise.reject();
} }
return post('/api/einrichtung/persoenlich', { return post('/api/einrichtung/persoenlich', {
name: form.querySelector('#einr-name').value, name: v('#einr-name'), adresse: v('#einr-adresse'), kundennummer: v('#einr-kundennummer'),
adresse: form.querySelector('#einr-adresse').value, email: v('#einr-email'), telefon: v('#einr-telefon'), ort: v('#einr-ort'),
kundennummer: form.querySelector('#einr-kundennummer').value, webseite: v('#einr-webseite'), geburtsdatum: v('#einr-geburtsdatum')
email: form.querySelector('#einr-email').value, }).then(check);
telefon: form.querySelector('#einr-telefon').value,
ort: form.querySelector('#einr-ort').value,
webseite: form.querySelector('#einr-webseite').value,
geburtsdatum: form.querySelector('#einr-geburtsdatum').value
}).then(function (r) {
if (!r.ok || !r.json.success) throw new Error(r.json.error || 'Speichern fehlgeschlagen');
});
} }
if (stepNr === 3) { if (stepNr === 3) {
var a = form.querySelector('#einr-anschreiben').value; return post('/api/einrichtung/basis', { typ: 'Anschreiben', inhalt: v('#einr-anschreiben') })
var l = form.querySelector('#einr-lebenslauf').value; .then(function () { return post('/api/einrichtung/basis', { typ: 'Lebenslauf', inhalt: v('#einr-lebenslauf') }); })
return post('/api/einrichtung/basis', { typ: 'Anschreiben', inhalt: a }) .then(check);
.then(function () { return post('/api/einrichtung/basis', { typ: 'Lebenslauf', inhalt: l }); })
.then(function (r) {
if (!r.ok || !r.json.success) throw new Error(r.json.error || 'Speichern fehlgeschlagen');
});
} }
if (stepNr === 4) { if (stepNr === 4) {
return post('/api/einrichtung/ollama', { return post('/api/einrichtung/ollama', {
OLLAMA_API_KEY: form.querySelector('#einr-apikey').value, OLLAMA_API_KEY: v('#einr-apikey'), OLLAMA_MODEL: v('#einr-model'), OLLAMA_HOST: v('#einr-host')
OLLAMA_MODEL: form.querySelector('#einr-model').value, }).then(check);
OLLAMA_HOST: form.querySelector('#einr-host').value }
}).then(function (r) { if (stepNr === 5) {
if (!r.ok || !r.json.success) throw new Error(r.json.error || 'Speichern fehlgeschlagen'); var data = {
}); MAIL_HOST: v('#einr-mail-host'), MAIL_SMTP_PORT: v('#einr-mail-smtp-port'),
MAIL_IMAP_PORT: v('#einr-mail-imap-port'), MAIL_USER: v('#einr-mail-user'),
MAIL_FROM_NAME: v('#einr-mail-fromname'), MAIL_FROM: v('#einr-mail-from'),
MAIL_IMAP_MAILBOX: v('#einr-mail-mailbox'), MAIL_POLL_MS: v('#einr-mail-poll'),
CALDAV_URL: v('#einr-caldav-url'), CALDAV_ALARM_MIN: v('#einr-caldav-alarm'),
CALDAV_POLL_MS: v('#einr-caldav-poll')
};
var pw = v('#einr-mail-password');
if (pw) data.MAIL_PASSWORD = pw; // leer = vorhandenes Passwort nicht überschreiben
return post('/api/einrichtung/config', data).then(check);
}
if (stepNr === 6) {
var token = v('#einr-api-token');
var data = {};
if (token) data.API_TOKEN = token; // leer = vorhandenen Token nicht anfassen
return post('/api/einrichtung/config', data).then(check);
} }
return Promise.resolve(); return Promise.resolve();
} }
@@ -474,12 +585,16 @@
btnWeiter.addEventListener('click', function () { btnWeiter.addEventListener('click', function () {
showFehler(''); showFehler('');
if (step === MAX) { if (step === MAX) {
// Assistent abschließen: dauerhaft ausblenden und zur Übersicht.
post('/api/einrichtung/verstecken', {}).then(function () { window.location.href = '/'; }); post('/api/einrichtung/verstecken', {}).then(function () { window.location.href = '/'; });
return; return;
} }
setBusy(true); setBusy(true);
speichern(step).then(function () { speichern(step).then(function () {
// Nach dem Speichern ggf. „vorhanden"-Flags aktualisieren, damit die
// Abschluss-Checkliste korrekt steht (z. B. neu erzeugter API-Token).
if (step === 4 && v('#einr-apikey')) VORHANDEN.apiKey = VORHANDEN.apiKey || !!v('#einr-apikey').trim();
if (step === 5 && v('#einr-mail-password')) VORHANDEN.mailPassword = VORHANDEN.mailPassword || !!v('#einr-mail-password').trim();
if (step === 6 && v('#einr-api-token')) VORHANDEN.apiToken = VORHANDEN.apiToken || !!v('#einr-api-token').trim();
setBusy(false); setBusy(false);
gehZu(step + 1); gehZu(step + 1);
}).catch(function (e) { }).catch(function (e) {
@@ -489,29 +604,42 @@
}); });
btnZurueck.addEventListener('click', function () { showFehler(''); gehZu(step - 1); }); btnZurueck.addEventListener('click', function () { showFehler(''); gehZu(step - 1); });
btnUeberspringen.addEventListener('click', function () { showFehler(''); gehZu(step + 1); });
btnDirekt.addEventListener('click', function () { if (typeof window.openAddApplicationModal === 'function') window.openAddApplicationModal(); });
btnAusblenden.addEventListener('click', function () { post('/api/einrichtung/verstecken', {}).then(function () { window.location.href = '/'; }); });
btnUeberspringen.addEventListener('click', function () { // Reveal-Toggle für alle Passwort-/Secret-Felder (ein shared Handler).
showFehler(''); Array.prototype.slice.call(form.querySelectorAll('.einr-reveal')).forEach(function (btn) {
// Überspringen speichert bewusst nicht — der Schritt gilt als offen. btn.addEventListener('click', function () {
gehZu(step + 1); var inp = document.getElementById(btn.getAttribute('data-target'));
});
btnDirekt.addEventListener('click', function () {
if (typeof window.openAddApplicationModal === 'function') window.openAddApplicationModal();
});
btnAusblenden.addEventListener('click', function () {
post('/api/einrichtung/verstecken', {}).then(function () { window.location.href = '/'; });
});
// API-Schlüssel anzeigen/verbergen.
var toggle = document.getElementById('einr-toggle-key');
if (toggle) {
toggle.addEventListener('click', function () {
var inp = document.getElementById('einr-apikey');
if (inp) inp.type = inp.type === 'password' ? 'text' : 'password'; if (inp) inp.type = inp.type === 'password' ? 'text' : 'password';
}); });
});
// API-Token neu generieren (32 Byte hex, clientseitig CSPRNG).
var gen = document.getElementById('einr-token-gen');
var copy = document.getElementById('einr-token-copy');
var hinweis = document.getElementById('einr-token-hint');
function tokenHint(text, ok) {
hinweis.textContent = text;
hinweis.classList.remove('hidden');
hinweis.classList.toggle('text-amber-600', !ok); hinweis.classList.toggle('dark:text-amber-400', !ok);
hinweis.classList.toggle('text-green-600', ok); hinweis.classList.toggle('dark:text-green-400', ok);
} }
if (gen) gen.addEventListener('click', function () {
var inp = document.getElementById('einr-api-token');
if (!inp) return;
var bytes = new Uint8Array(32); crypto.getRandomValues(bytes);
inp.value = Array.from(bytes).map(function (b) { return b.toString(16).padStart(2, '0'); }).join('');
inp.type = 'text';
tokenHint('Neuer Token erzeugt — zum Aktivieren „Weiter". Der alte wird damit ungültig.', false);
});
if (copy) copy.addEventListener('click', async function () {
var inp = document.getElementById('einr-api-token');
if (!inp || !inp.value) return;
try { await navigator.clipboard.writeText(inp.value); tokenHint('In die Zwischenablage kopiert.', true); }
catch (e) { inp.type = 'text'; inp.select(); tokenHint('Kopieren nicht möglich — Wert ist markiert.', false); }
});
render(); render();
})(); })();