WLAN-Profile, portables OpenVPN und automatischer AD-Join

- WLAN-Tab: SSIDs (WPA2/WPA3/offen, versteckt) in Blaupausen speichern,
  fuer alle Benutzer anlegen, Profile eines Referenzgeraets uebernehmen
- OpenVPN portabel: offizielles MSI per msiexec /a entpackt + Microsoft-
  signierter TAP-Treiber in tools\openvpn; Treiber wird nur temporaer
  geladen und danach entfernt (--disable-dco, --dev-node)
- .ovpn/Zertifikate je Kunde hochladen, Benutzer/Kennwort optional
- AD-Join mit DC-Test (SRV+LDAP), OU, Umbenennung, lokalen Admins,
  optional ueber VPN; VPN optional als Dienst fuer die erste Anmeldung
- Fehlende Kennwoerter werden beim Start abgefragt und nicht gespeichert

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-09 16:29:07 +02:00
co-authored by Claude Opus 5.5
parent ebfec7000e
commit 46a969ee90
8 changed files with 692 additions and 4 deletions
+145 -3
View File
@@ -43,6 +43,9 @@ const DEFAULTS = () => ({
security: { defender: true, firewall: true, smartScreen: true, uac: false, disableSmb1: true, disableAutorun: true, lsaProtection: false, telemetryMin: false, rdp: 'keep', localAdminName: '', localAdminPassword: '' },
power: { plan: 'balanced', monitorAc: 15, monitorDc: 5, sleepAc: 0, sleepDc: 15, hibernateAc: 0, hibernateDc: 60, lidAc: 'nothing', lidDc: 'sleep', buttonAc: 'shutdown', buttonDc: 'shutdown', hibernate: '', fastStartup: 'off' },
bloatware: { recommended: true, ids: [], keep: [] },
wifi: [],
vpn: { config: '', user: '', password: '', dns: '' },
domain: { name: '', ou: '', user: '', password: '', admins: '', useVpn: false, vpnService: false, reboot: true },
bitlocker: { method: 'XtsAes256', usedSpaceOnly: true, skipHardwareTest: true, usePin: false, pin: '', backupAad: false, backupAd: false },
steps: ['system', 'security', 'power', 'apps', 'office', 'shortcuts', 'updates']
});
@@ -98,7 +101,7 @@ function loadState(obj) { S = deepMerge(DEFAULTS(), obj); persist(); fillForms()
function showTab(t) {
$$('#nav a').forEach(a => a.classList.toggle('active', a.dataset.tab === t));
$$('.tab').forEach(s => s.classList.toggle('active', s.id === 'tab-' + t));
({ shortcuts: loadShortcuts, bitlocker: loadBitlocker, bloatware: () => { if (!bloat) scanBloat(); }, profiles: loadProfiles, jobs: pollJobs, power: loadPower, office: loadOfficeBuilds })[t]?.();
({ shortcuts: loadShortcuts, bitlocker: loadBitlocker, bloatware: () => { if (!bloat) scanBloat(); }, wifi: loadWifi, vpn: loadVpn, profiles: loadProfiles, jobs: pollJobs, power: loadPower, office: loadOfficeBuilds })[t]?.();
}
$('#nav').onclick = e => { const a = e.target.closest('a'); if (a) showTab(a.dataset.tab); };
@@ -117,6 +120,8 @@ function renderSummary() {
$('#summary').innerHTML = [
`<b>${arr(S.apps).length}</b> Programme`, `Office: <b>${esc(prod?.name || o.product)}</b> (${o.arch}-Bit, ${esc(S.officeMode)})`,
`<b>${arr(S.shortcuts).length}</b> Desktop-Icons`, `Bloatware: <b>${S.bloatware.recommended ? 'Empfohlene' : ''}${S.bloatware.recommended && arr(S.bloatware.ids).length ? ' + ' : ''}${arr(S.bloatware.ids).length ? arr(S.bloatware.ids).length + ' gewählt' : (S.bloatware.recommended ? '' : 'keine')}</b>`, `Energieplan: <b>${esc(S.power.plan || 'unverändert')}</b>`,
arr(S.wifi).length ? `<b>${arr(S.wifi).length}</b> WLAN(s)` : '',
S.domain.name ? `Domäne: <b>${esc(S.domain.name)}</b>${S.domain.useVpn ? ' (per VPN)' : ''}` : '',
S.system.computerName ? `Neuer Name: <b>${esc(S.system.computerName)}</b>` : ''
].filter(Boolean).join(' · ');
}
@@ -125,7 +130,9 @@ $('#btnRunAll').onclick = () => guard(async () => {
if (!S.steps.length) return toast('Keine Schritte ausgewählt', true);
if (S.steps.includes('bitlocker')) S.bitlocker.action = 'enable';
if (!confirm(`Einrichtung mit ${S.steps.length} Schritten jetzt starten?`)) return;
const r = await api('POST', '/api/run-profile', S); jobStarted(r.job);
const data = await withSecrets(S, S.steps.includes('domain'), S.steps.includes('domain') && S.domain.useVpn);
if (!data) return;
const r = await api('POST', '/api/run-profile', data); jobStarted(r.job);
});
// ---------------------------------------------------------------- Programme
@@ -452,6 +459,141 @@ $('#btnVendor').onclick = () => guard(async () => jobStarted((await api('POST',
$('#btnWinget').onclick = () => guard(async () => jobStarted((await api('POST', '/api/winget/install', {})).job));
$('#btnReboot').onclick = () => { if (confirm('Computer jetzt neu starten?')) guard(() => api('POST', '/api/reboot', {})); };
// ---------------------------------------------------------------- Kennwortabfrage
function askSecret(title, text) {
return new Promise(resolve => {
const d = $('#secretDlg');
$('#secretTitle').textContent = title; $('#secretText').textContent = text || ''; $('#secretInput').value = '';
d.onclose = () => resolve(d.returnValue === 'ok' ? $('#secretInput').value : null);
d.showModal(); $('#secretInput').focus();
});
}
// Fehlende Kennwörter (nicht in der Blaupause gespeichert) nur für diesen Lauf abfragen
async function withSecrets(state, needDomain, needVpn) {
const d = JSON.parse(JSON.stringify(state));
if (needDomain) {
if (!d.domain.name || !d.domain.user) { toast('Domäne und Benutzer angeben (Tab „VPN & Domäne“)', true); return null; }
if (!d.domain.password) {
const p = await askSecret('Domänen-Kennwort', `Kennwort für ${d.domain.user} (${d.domain.name})`); if (p == null) return null; d.domain.password = p;
}
}
if (needVpn) {
if (!d.vpn.config) { toast('Keine VPN-Konfiguration ausgewählt', true); return null; }
if (d.vpn.user && !d.vpn.password) {
const p = await askSecret('VPN-Kennwort', `Kennwort für VPN-Benutzer ${d.vpn.user}`); if (p == null) return null; d.vpn.password = p;
}
}
return d;
}
// ---------------------------------------------------------------- WLAN
$('#wfAuth').onchange = () => $('#wfKeyRow').classList.toggle('hidden', $('#wfAuth').value === 'open');
$('#wfShow').onclick = () => { const i = $('#wfKey'); i.type = i.type === 'password' ? 'text' : 'password'; };
$('#btnWfAdd').onclick = () => {
const w = { ssid: $('#wfSsid').value.trim(), auth: $('#wfAuth').value, key: $('#wfKey').value, auto: $('#wfAuto').checked, hidden: $('#wfHidden').checked, connect: $('#wfConnect').checked };
if (!w.ssid) return toast('SSID angeben', true);
if (w.auth !== 'open' && w.key.length < 8) return toast('Kennwort muss mind. 8 Zeichen haben', true);
S.wifi = arr(S.wifi).filter(x => x.ssid !== w.ssid).concat(w); persist(); renderWifi();
$('#wfSsid').value = ''; $('#wfKey').value = '';
};
const AUTH_NAMES = { WPA2PSK: 'WPA2', WPA3SAE: 'WPA3', open: 'offen' };
function renderWifi() {
const l = arr(S.wifi);
$('#wfCount').textContent = l.length;
$('#wfList').innerHTML = l.length ? l.map((w, i) => `<div class="item"><span>📶</span><div class="grow"><div>${esc(w.ssid)}</div>
<div class="sub">${AUTH_NAMES[w.auth] || w.auth}${w.hidden ? ' · versteckt' : ''}${w.auto === false ? ' · manuell' : ' · automatisch'}${w.connect ? ' · verbinden' : ''}</div></div>
<button class="small ghost" data-wedit="${i}" title="Bearbeiten">✎</button><button class="small ghost" data-wdel="${i}">✕</button></div>`).join('')
: '<p class="muted">Keine WLANs in der Blaupause.</p>';
}
$('#wfList').onclick = e => {
const { wdel, wedit } = e.target.dataset;
if (wdel != null) { S.wifi.splice(wdel, 1); persist(); renderWifi(); }
if (wedit != null) {
const w = S.wifi[wedit];
$('#wfSsid').value = w.ssid; $('#wfAuth').value = w.auth; $('#wfKey').value = w.key || '';
$('#wfAuto').checked = w.auto !== false; $('#wfHidden').checked = !!w.hidden; $('#wfConnect').checked = !!w.connect;
$('#wfAuth').onchange();
}
};
$('#btnWfApply').onclick = () => guard(async () => {
if (!arr(S.wifi).length) return toast('Keine WLANs in der Blaupause', true);
jobStarted((await api('POST', '/api/wifi/apply', { wifi: S.wifi })).job);
});
async function loadWifi() {
await guard(async () => {
const s = await api('GET', '/api/wifi');
$('#wfState').innerHTML = s.hasWifi ? (s.connected ? `Verbunden mit <b>${esc(s.connected)}</b>` : 'Nicht mit einem WLAN verbunden') : 'Kein WLAN-Adapter gefunden.';
$('#wfNetworks').innerHTML = arr(s.networks).map(n => `<option value="${esc(n)}">`).join('');
$('#wfDevice').innerHTML = arr(s.profiles).length ? arr(s.profiles).map(p => `<div class="item"><div class="grow">${esc(p)}</div>
<button class="small" data-wconn="${esc(p)}">Verbinden</button><button class="small danger" data-wrm="${esc(p)}">✕</button></div>`).join('')
: '<p class="muted small">Keine Profile gespeichert.</p>';
});
}
$('#wfDevice').onclick = e => guard(async () => {
const { wconn, wrm } = e.target.dataset;
if (wconn) { const r = await api('POST', '/api/wifi/connect', { ssid: wconn }); toast(r.ok ? `Verbinde mit ${wconn} …` : r.message, !r.ok); setTimeout(loadWifi, 4000); }
if (wrm && confirm(`WLAN-Profil "${wrm}" von diesem Gerät löschen?`)) { await api('DELETE', '/api/wifi?ssid=' + encodeURIComponent(wrm)); loadWifi(); }
});
$('#btnWfRefresh').onclick = loadWifi;
$('#btnWfImport').onclick = () => guard(async () => {
const list = arr(await api('POST', '/api/wifi/import', {}));
if (!list.length) return toast('Keine übernehmbaren WLAN-Profile gefunden', true);
const names = new Set(list.map(w => w.ssid));
S.wifi = arr(S.wifi).filter(x => !names.has(x.ssid)).concat(list); persist(); renderWifi();
toast(`${list.length} WLAN(s) übernommen – Blaupause speichern nicht vergessen`);
});
// ---------------------------------------------------------------- VPN & Domäne
async function loadVpn() {
await guard(async () => {
const v = await api('GET', '/api/vpn');
const p = v.portable;
$('#vpnPortable').innerHTML = p.portable && p.driver
? `<span class="okline">✓ Portables OpenVPN vorhanden</span> – ${esc(p.version)} <button class="small ghost" id="btnVpnBuild">aktualisieren</button>`
: `<span class="errline">Portables OpenVPN fehlt auf dem Stick.</span> <button class="small primary" id="btnVpnBuild">Jetzt erstellen</button>
<br>Lädt einmalig die offiziellen Pakete (OpenVPN + Microsoft-signierter TAP-Treiber) nach <code>tools\\openvpn\\</code>. Danach läuft alles ohne Installation vom Stick.`;
$('#btnVpnBuild').onclick = () => guard(async () => jobStarted((await api('POST', '/api/vpn/build', {})).job));
const files = arr(v.files).filter(f => /\.(ovpn|conf)$/i.test(f));
$('#vpnConfig').innerHTML = '<option value="">– keine –</option>' + files.map(f => `<option value="${esc(f)}">${esc(f)}</option>`).join('');
$('#vpnConfig').value = files.includes(S.vpn.config) ? S.vpn.config : '';
const s = v.status;
$('#vpnStatus').innerHTML = `<div>${s.connected ? `<span class="okline">● Verbunden</span> ${esc(s.ip)}` : s.running ? '<span style="color:var(--warn)">● Verbindungsaufbau …</span>' : '<span class="muted">○ Nicht verbunden</span>'}
${s.kdsService ? `<span class="muted small"> · Als Dienst eingerichtet: ${esc(s.service)}</span> <button class="small ghost" id="btnVpnSvcDel">Dienst-Konfiguration entfernen</button>` : ''}</div>
${s.log ? `<details><summary class="muted small">OpenVPN-Log</summary><pre class="code">${esc(s.log)}</pre></details>` : ''}`;
const del = $('#btnVpnSvcDel');
if (del) del.onclick = () => guard(async () => { if (confirm('Dauerhafte VPN-Konfiguration vom Gerät entfernen?')) { await api('DELETE', '/api/vpn/service'); loadVpn(); } });
});
}
$('#vpnUpload').onchange = async e => {
const up = await uploadMany('vpn', [...e.target.files]); e.target.value = '';
const ovpn = up.find(u => /\.(ovpn|conf)$/i.test(u.name));
if (ovpn) { S.vpn.config = ovpn.name; persist(); }
loadVpn();
};
$('#btnVpnConnect').onclick = () => guard(async () => {
const d = await withSecrets(S, false, true); if (!d) return;
const r = await api('POST', '/api/vpn/connect', { vpn: d.vpn }); jobStarted(r.job, true);
$('#vpnStatus').innerHTML = '<span style="color:var(--warn)">● Verbindungsaufbau …</span>';
const j = await waitJob(r.job);
toast(j.state === 'Fertig' ? 'VPN verbunden' : 'VPN-Verbindung fehlgeschlagen – siehe Log', j.state !== 'Fertig', () => { selJob = r.job; showTab('jobs'); });
loadVpn();
});
$('#btnVpnDisconnect').onclick = () => guard(async () => { await api('POST', '/api/vpn/disconnect', {}); toast('VPN getrennt'); loadVpn(); });
$('#btnVpnRefresh').onclick = loadVpn;
$('#btnDomTest').onclick = () => guard(async () => {
if (!S.domain.name) return toast('Domäne angeben', true);
$('#domTest').textContent = 'Teste …';
const t = await api('POST', '/api/domain/test', { name: S.domain.name });
$('#domTest').innerHTML = t.joined ? '<span class="okline">✓ Gerät ist bereits Mitglied dieser Domäne</span>'
: t.dns ? `<span class="okline">✓ DCs gefunden:</span> ${arr(t.dcs).map(esc).join(', ')} · LDAP ${t.ldap ? '<span class="okline">erreichbar</span>' : '<span class="errline">nicht erreichbar</span>'}`
: `<span class="errline">✗ Kein Domänencontroller per DNS gefunden.</span> VPN verbinden oder DNS-Server für den Tunnel setzen.`;
});
$('#btnDomJoin').onclick = () => guard(async () => {
const d = await withSecrets(S, true, S.domain.useVpn); if (!d) return;
if (!confirm(`Gerät jetzt der Domäne ${d.domain.name} hinzufügen?${d.domain.reboot ? '\nDanach wird neu gestartet.' : ''}`)) return;
jobStarted((await api('POST', '/api/domain/join', d)).job);
});
// ---------------------------------------------------------------- Bloatware
let bloat = null;
const bloatChecked = it => arr(S.bloatware.ids).includes(it.id) || (S.bloatware.recommended && it.recommended && !arr(S.bloatware.keep).includes(it.id));
@@ -620,7 +762,7 @@ async function waitJob(id) {
$('#btnShutdown').onclick = () => { if (confirm('Agent beenden? Laufende Aufgaben werden abgebrochen.')) api('POST', '/api/shutdown', {}).finally(() => { document.body.innerHTML = '<main><h1>Agent beendet.</h1><p>Das Fenster kann geschlossen werden.</p></main>'; }); };
// ---------------------------------------------------------------- Start
function renderAll() { renderOffice(); renderSelectedApps(); renderPlanned(); renderSummary(); }
function renderAll() { renderOffice(); renderSelectedApps(); renderPlanned(); renderWifi(); renderSummary(); }
(async () => {
if (!TOKEN) { document.body.innerHTML = '<main><h1>Kein Zugriffstoken</h1><p>Bitte die vollständige URL (mit ?token=…) aus dem Agent-Fenster öffnen.</p></main>'; return; }
bindForms(); renderAll(); fillForms(); scTypeUi();
+102
View File
@@ -15,6 +15,8 @@
<a data-tab="apps">Programme</a>
<a data-tab="office">Office</a>
<a data-tab="shortcuts">Desktop-Icons</a>
<a data-tab="wifi">WLAN</a>
<a data-tab="vpn">VPN &amp; Domäne</a>
<a data-tab="bloatware">Bloatware</a>
<a data-tab="system">System &amp; Sicherheit</a>
<a data-tab="power">Energie</a>
@@ -43,6 +45,7 @@
<p id="bpQuickDesc" class="muted small"></p>
<div class="checks" id="steps">
<label><input type="checkbox" value="system" checked> System</label>
<label><input type="checkbox" value="wifi"> WLAN</label>
<label><input type="checkbox" value="bloatware"> Bloatware</label>
<label><input type="checkbox" value="security" checked> Sicherheit</label>
<label><input type="checkbox" value="power" checked> Energie</label>
@@ -51,6 +54,7 @@
<label><input type="checkbox" value="shortcuts" checked> Desktop-Icons</label>
<label><input type="checkbox" value="drivers"> Hersteller-Treiber</label>
<label><input type="checkbox" value="updates" checked> Windows Update</label>
<label><input type="checkbox" value="domain"> VPN &amp; Domäne</label>
<label><input type="checkbox" value="bitlocker"> BitLocker</label>
</div>
<div id="summary" class="summary"></div>
@@ -242,6 +246,96 @@
</div>
</section>
<!-- WLAN -->
<section id="tab-wifi" class="tab">
<h1>WLAN</h1>
<div class="cols">
<div>
<div class="card">
<h2>WLAN zur Blaupause hinzufügen</h2>
<div class="row" style="margin-top:0">
<label class="f" style="flex:2">SSID<input id="wfSsid" list="wfNetworks" placeholder="Netzwerkname"></label>
<label class="f">Sicherheit<select id="wfAuth"><option value="WPA2PSK">WPA2-Personal</option><option value="WPA3SAE">WPA3-Personal</option><option value="open">Offen</option></select></label>
</div>
<datalist id="wfNetworks"></datalist>
<div class="f" id="wfKeyRow">Kennwort<div class="row" style="margin:3px 0 0"><input id="wfKey" type="password" autocomplete="new-password"><button class="small ghost" id="wfShow" type="button">👁</button></div></div>
<div class="checks">
<label><input type="checkbox" id="wfAuto" checked> Automatisch verbinden</label>
<label><input type="checkbox" id="wfHidden"> Verstecktes Netzwerk</label>
<label><input type="checkbox" id="wfConnect"> Nach dem Anlegen verbinden</label>
</div>
<button class="primary" id="btnWfAdd">Hinzufügen</button>
</div>
<div class="card">
<h2>In der Blaupause <span id="wfCount" class="badge">0</span></h2>
<div id="wfList" class="list"></div>
<button class="primary" id="btnWfApply">Jetzt auf diesem Gerät anlegen</button>
<p class="muted small">Profile werden für <b>alle Benutzer</b> gespeichert. Kennwörter liegen im Klartext in der Blaupause.</p>
</div>
</div>
<div>
<div class="card">
<h2>Dieses Gerät <button class="small ghost" id="btnWfRefresh">↻</button></h2>
<div id="wfState" class="muted small"></div>
<h3>Gespeicherte Profile</h3>
<div id="wfDevice" class="list"></div>
<button id="btnWfImport">Profile dieses Geräts in die Blaupause übernehmen</button>
<p class="muted small">Praktisch, um WLANs vom eigenen Techniker-Notebook oder einem Referenzgerät zu übernehmen (inkl. Kennwörter; Enterprise-WLANs werden übersprungen).</p>
</div>
</div>
</div>
</section>
<!-- VPN & Domäne -->
<section id="tab-vpn" class="tab">
<h1>VPN &amp; Domäne</h1>
<div class="cols">
<div>
<div class="card">
<h2>OpenVPN (portabel vom Stick)</h2>
<div id="vpnPortable" class="muted small"></div>
<div class="f">Konfiguration (.ovpn)<div class="row" style="margin:3px 0 0"><select id="vpnConfig" data-bind="vpn.config"></select>
<label class="button">Hochladen<input type="file" id="vpnUpload" accept=".ovpn,.conf,.crt,.pem,.key,.p12,.pfx" multiple hidden></label></div></div>
<p class="muted small">Zertifikate/Schlüssel, auf die die .ovpn verweist, einfach mit hochladen (Ordner <code>vpn\</code>).</p>
<div class="row">
<label class="f">VPN-Benutzer (falls nötig)<input data-bind="vpn.user" autocomplete="off"></label>
<label class="f">VPN-Kennwort<input data-bind="vpn.password" type="password" autocomplete="new-password" placeholder="leer = beim Start fragen"></label>
</div>
<label class="f">DNS-Server für den Tunnel (optional)<input data-bind="vpn.dns" placeholder="z.B. 10.0.0.10, 10.0.0.11 – nur falls das VPN keinen DNS mitliefert"></label>
<div class="row">
<button class="primary" id="btnVpnConnect">Verbinden</button>
<button id="btnVpnDisconnect">Trennen</button>
<button class="ghost" id="btnVpnRefresh">↻ Status</button>
</div>
<div id="vpnStatus" style="margin-top:12px"></div>
</div>
</div>
<div>
<div class="card">
<h2>Domänenbeitritt (Active Directory)</h2>
<div class="row" style="margin-top:0">
<label class="f">Domäne (FQDN)<input data-bind="domain.name" placeholder="firma.local"></label>
<button id="btnDomTest">Erreichbarkeit testen</button>
</div>
<div id="domTest" class="muted small"></div>
<label class="f">Ziel-OU (optional)<input data-bind="domain.ou" placeholder="OU=Notebooks,OU=Computer,DC=firma,DC=local"></label>
<div class="row">
<label class="f">Benutzer mit Beitrittsrecht<input data-bind="domain.user" placeholder="FIRMA\admin oder [email protected]" autocomplete="off"></label>
<label class="f">Kennwort<input data-bind="domain.password" type="password" autocomplete="new-password" placeholder="leer = beim Start fragen"></label>
</div>
<label class="f">Lokale Administratoren hinzufügen (optional)<input data-bind="domain.admins" placeholder="z.B. IT-Admins, FIRMA\Helpdesk"></label>
<p class="muted small">Neuer Computername kommt aus „System &amp; Sicherheit“ und wird beim Beitritt gleich mit gesetzt.</p>
<div class="checks col">
<label><input type="checkbox" data-bind="domain.useVpn"> Vorher OpenVPN verbinden</label>
<label><input type="checkbox" data-bind="domain.vpnService"> VPN danach dauerhaft als Dienst einrichten (für die erste Domänenanmeldung außerhalb des Firmennetzes – installiert OpenVPN offline vom Stick)</label>
<label><input type="checkbox" data-bind="domain.reboot"> Danach neu starten</label>
</div>
<button class="primary" id="btnDomJoin">Domäne beitreten</button>
</div>
</div>
</div>
</section>
<!-- Bloatware -->
<section id="tab-bloatware" class="tab">
<h1>Bloatware entfernen</h1>
@@ -417,6 +511,14 @@
</section>
</main>
<dialog id="secretDlg">
<form method="dialog">
<h2 id="secretTitle">Kennwort</h2>
<p id="secretText" class="muted small"></p>
<input id="secretInput" type="password" autocomplete="new-password" style="width:100%">
<div class="row"><button value="cancel" class="ghost">Abbrechen</button><button value="ok" class="primary">OK</button></div>
</form>
</dialog>
<div id="toast"></div>
<script src="app.js"></script>
</body>
+4
View File
@@ -97,3 +97,7 @@ textarea { font: inherit; color: var(--text); background: var(--bg); border: 1px
details summary { cursor: pointer; margin: 6px 0; }
input[hidden] { display: none !important; }
div.f { margin-bottom: 10px; font-size: 12px; color: var(--muted); }
dialog { border: 1px solid var(--line); border-radius: 12px; background: var(--card); color: var(--text); padding: 20px; width: min(420px, 90vw); }
dialog::backdrop { background: rgba(0,0,0,.45); }
.okline { color: var(--ok); font-weight: 600; } .errline { color: var(--err); font-weight: 600; }
.checks label input { flex: none; }