/* global S, T, h, $, $$, esc, ICONS, api, call, toast, modal, confirmBox, promptBox, uid, avatar, hostSub, hostRef, addTab, closeTab, setTabState, openTerminal, settings, fmtDate, relTime, openDocker, openFirewall, openNetwork */
'use strict';
// Feature-Menüs (Docker, Firewall, Netzwerk, Webserver, Zertifikate, Samba/AD): Liste, Ein-/Ausschalten in den
// Einstellungen und die Verwaltungs-Tabs Webserver, Zertifikate und Samba/AD (Backend: src/main/admin.js & Co.).
// Wird nach app.js geladen und nutzt dessen Hilfsfunktionen.
Object.assign(ICONS, {
globe: ' ',
cert: ' ',
users: ' ',
});
const FEATURES = [
{ id: 'docker', label: () => 'Docker', title: () => T('Docker containers'), icon: 'docker', color: 'var(--accent-2)', open: (x) => openDocker(x) },
{ id: 'firewall', label: () => T('Firewall'), icon: 'wall', color: 'var(--orange)', open: (x) => openFirewall(x) },
{ id: 'network', label: () => T('Network'), icon: 'network', color: 'var(--accent)', open: (x) => openNetwork(x) },
{ id: 'webserver', label: () => T('Web server'), icon: 'globe', color: 'var(--green)', open: (x) => new WebServerSession(x) },
{ id: 'certs', label: () => T('Certificates'), icon: 'cert', color: 'var(--accent-3)', open: (x) => new CertSession(x) },
{ id: 'samba', label: () => 'Samba / AD', icon: 'users', color: 'var(--folder)', open: (x) => new SambaSession(x) },
];
const featureOn = (id) => settings().features?.[id] !== false;
const enabledFeatures = () => FEATURES.filter((f) => featureOn(f.id));
const featureOpeners = () => Object.fromEntries(FEATURES.map((f) => [f.id, f.open]));
const featureTitle = (f) => (f.title ? f.title() : f.label());
// Einstellungen: Feature-Menüs ein-/ausschalten
function featuresCard(save) {
const card = h(`
${T('Feature menus')} ${T('Choose which management tabs are offered for SSH hosts (host menu, terminal toolbar and quick search).')}
`);
for (const f of FEATURES) {
const el = h(`${ICONS[f.icon]} ${esc(featureTitle(f))} `);
$('input', el).onchange = (e) => save({ features: { ...(settings().features || {}), [f.id]: e.target.checked } });
$('.feature-list', card).append(el);
}
return card;
}
const pill = (state) => {
const on = state === 'active';
const label = { active: T('active'), inactive: T('inactive'), failed: T('failed'), activating: T('starting') }[state] || state || T('unknown');
return `${esc(label)} `;
};
const daysLeft = (t) => (t ? Math.floor((t - Date.now()) / 86400000) : null);
function expiryHtml(t) {
if (!t) return '– ';
const d = daysLeft(t);
const cls = d < 0 ? 'err' : d < 14 ? 'err' : d < 30 ? 'warn' : 'ok';
return `${esc(fmtDate(t))}${d < 0 ? esc(T('expired')) : esc(T('{n} days', { n: d }))} `;
}
const spinnerHtml = (text) => ``;
// Formular-Dialog mit Logik nach dem Öffnen (z. B. Felder je nach Auswahl ein-/ausblenden)
function formModal(opts, wire) {
const p = modal(opts);
const m = $$('#modalRoot .modal-bg').pop();
if (m && wire) wire(m);
return p;
}
// ============================================================ Basis für Verwaltungs-Tabs
class AdminSession {
constructor(host, { kind, ch, title, views }) {
this.kind = kind; this.ch = ch; this.id = uid(); this.host = host;
this.title = `${title} · ${host.label || host.address}`;
this.views = views; this.view = views[0][0]; this.data = null;
this.el = h(`
${avatar(host, 22)}${esc(hostSub(host))}
${views.map(([v, l]) => `${esc(l)} `).join('')}
${ICONS.ai}
${ICONS.refresh}
${spinnerHtml(T('Connecting to {host} …', { host: host.address }))}
`);
this.body = $('.admin-body', this.el);
this.el.addEventListener('click', (e) => {
const v = e.target.closest('.views [data-v]')?.dataset.v;
if (v && this.data) this.setView(v);
if (e.target.closest('[data-a=refresh]') && this.data) this.refresh();
});
this.unsub = api.on(`${ch}:closed`, (sid) => { if (sid === this.id) { setTabState(this, 'err'); this.error(T('Connection closed.')); } });
addTab(this);
this.open();
}
async open() {
try {
this.data = await api.call(`${this.ch}:open`, this.id, hostRef(this.host));
setTabState(this, 'on');
this.draw();
} catch (e) {
setTabState(this, 'err');
this.error(e.message);
}
}
error(msg) {
this.body.innerHTML = ``;
const b = h(`${ICONS.refresh}${T('Try again')} `);
b.onclick = () => { closeTab(this.id); featureOpeners()[this.kind](this.host); };
$('.pane-empty', this.body).append(b);
}
setView(v) {
this.view = v;
$$('.views [data-v]', this.el).forEach((b) => b.classList.toggle('active', b.dataset.v === v));
this.body.scrollTop = 0;
this.draw();
}
async refresh() {
try { this.data = await api.call(`${this.ch}:read`, this.id); this.draw(); } catch (e) { toast(e.message, 'error'); }
}
// Operation im Backend; Fehler werden als Toast gezeigt und weitergeworfen
op(name, ...args) { return call(`${this.ch}:op`, this.id, name, ...args); }
// Operation + Neuladen; true bei Erfolg
async act(name, args, okMsg) {
try { await this.op(name, ...args); } catch { return false; }
if (okMsg) toast(okMsg, 'ok');
await this.refresh();
return true;
}
// Befehl vom Backend im Terminal-Tab ausführen (Live-Ausgabe, Rückfragen möglich)
async term(name, args, label) {
try { openTerminal({ ...this.host, execCommand: await this.op(name, ...args), label: `${label} · ${this.host.label || this.host.address}` }); } catch {}
}
draw() {
if (!this.data || this.editing) return;
const scroll = this.body.scrollTop;
this.body.innerHTML = '';
const wrap = h('
');
this.body.append(wrap);
this[`draw_${this.view}`](wrap);
this.body.scrollTop = scroll;
}
section(title, actions = []) {
const sec = h(``);
for (const a of actions) {
const b = h(`${a.icon ? ICONS[a.icon] : ''}${esc(a.label)} `);
b.onclick = a.run;
$('.admin-sec-head', sec).append(b);
}
return sec;
}
// Tabelle: cols = [[Titel, (zeile) => html, klasse?]], acts = (zeile) => [{ icon, title, run, danger }]
table(rows, cols, acts) {
const t = h(`${cols.map(([c, , cls]) => `${esc(c)} `).join('')}${acts ? ' ' : ''}
`);
for (const r of rows) {
const tr = h(`${cols.map(([, f, cls]) => `${f(r)} `).join('')}${acts ? ' ' : ''} `);
if (acts) for (const a of acts(r).filter(Boolean)) {
const b = h(`${a.icon ? ICONS[a.icon] : ''}${a.label ? esc(a.label) : ''} `);
b.onclick = a.run;
$('.acts', tr).append(b);
}
$('tbody', t).append(tr);
}
return t;
}
empty(text) { return h(`${esc(text)}
`); }
// Dienst-Zeile mit Start/Stopp/Neustart/Neu laden und Autostart
serviceButtons(u, opName = 'service', withReload = true) {
const box = h(' ');
const run = (action, msg) => this.act(opName, [u.unit, action], msg);
const btn = (icon, title, fn, cls = '') => { const b = h(`${ICONS[icon]} `); b.onclick = fn; box.append(b); };
if (u.active === 'active') btn('stop', T('Stop'), async () => { if (await confirmBox(T('Stop {name}?', { name: u.unit }), T('The service will stop serving requests.'), T('Stop'))) run('stop', T('{name} stopped', { name: u.unit })); });
else btn('play', T('Start'), () => run('start', T('{name} started', { name: u.unit })));
btn('refresh', T('Restart'), () => run('restart', T('{name} restarted', { name: u.unit })));
if (withReload && u.active === 'active') btn('download', T('Reload configuration'), () => run('reload', T('Configuration reloaded')));
const en = h(` ${T('Autostart')} `);
$('input', en).onchange = (e) => run(e.target.checked ? 'enable' : 'disable');
box.append(en);
return box;
}
// Datei im Tab bearbeiten; save(content) → [Operation, ...Argumente]; Ergebnis: Ausgabe der Prüfung oder { path, output }
// onSaved(ergebnis) z. B. um nach dem ersten Speichern einer neuen Datei auf "überschreiben" umzustellen
async editFile(path, { read, save, note, onSaved }) {
let content;
try { content = await read(); } catch { return; }
this.editing = path;
this.body.innerHTML = '';
const ed = h(`${esc(path)}
${T('Back')} ${ICONS.download}${T('Save & apply')}
${note ? `
${esc(note)}
` : ''}
`);
const ta = $('textarea', ed);
ta.value = content;
ta.addEventListener('keydown', (e) => {
if (e.key === 'Tab') { e.preventDefault(); ta.setRangeText(' ', ta.selectionStart, ta.selectionEnd, 'end'); }
if (e.key === 's' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); $('[data-e=save]', ed).click(); }
});
const out = $('.se-out', ed);
$('[data-e=cancel]', ed).onclick = () => { this.editing = null; this.refresh(); };
$('[data-e=save]', ed).onclick = async (e) => {
const b = e.currentTarget; b.disabled = true;
try {
const r = await api.call(`${this.ch}:op`, this.id, ...save(ta.value));
onSaved?.(r);
if (r?.path) $('.se-head b', ed).textContent = r.path;
const msg = typeof r === 'string' ? r : r?.output;
out.style.display = ''; out.className = 'se-out ok'; out.textContent = (typeof msg === 'string' && msg.trim()) || T('Saved and applied.');
toast(T('Saved'), 'ok');
} catch (err) {
out.style.display = ''; out.className = 'se-out err'; out.textContent = err.message;
}
b.disabled = false;
};
this.body.append(ed);
ta.focus();
}
aiContext() { return this.body.innerText; }
dispose() { this.unsub(); api.call(`${this.ch}:close`, this.id).catch(() => {}); }
}
// ============================================================ Webserver (nginx, Apache, Caddy, PHP-FPM)
const SRV_LABEL = { nginx: 'nginx', apache: 'Apache', caddy: 'Caddy', php: 'PHP-FPM' };
class WebServerSession extends AdminSession {
constructor(host) {
super(host, { kind: 'webserver', ch: 'web', title: T('Web server'), views: [['overview', T('Overview')], ['sites', T('Sites')], ['generator', T('Generator')], ['files', T('Configuration')]] });
}
// ---------- Konfigurations-Generator: alle Optionen durchsuchbar, Live-Vorschau, als Site speichern oder im Editor öffnen
draw_generator(w) {
const W = window.WEBCONF;
const inst = this.installed().map((x) => x.id);
const g = this.gen ||= { server: inst[0] || 'nginx', o: W.defaults(), q: '' };
const nginxVersion = (this.data.servers.find((x) => x.id === 'nginx')?.version.match(/nginx\/([\d.]+)/) || [])[1];
const box = h(`
${['nginx', 'apache', 'caddy'].map((x) => `${SRV_LABEL[x]}${inst.includes(x) ? '' : ' · '} `).join('')}
${ICONS.copy}
${ICONS.edit}${T('Open in editor')}
${ICONS.download}${T('Save as site')}
`);
w.append(box);
w.classList.add('gen-wrap');
const optsBox = $('.gen-opts', box);
const rows = [];
for (const [gid, glabel] of W.GROUPS) {
const sec = h(`
${esc(T(glabel))} `);
for (const opt of W.OPTIONS.filter((x) => x.group === gid)) {
const v = g.o[opt.id];
let input;
if (opt.type === 'bool') input = ` `;
else if (opt.type === 'select') input = `${opt.options.map(([k, l]) => `${esc(T(l))} `).join('')} `;
else if (opt.type === 'textarea') input = ``;
else input = ` `;
const only = opt.only ? `${esc(opt.only.map((x) => SRV_LABEL[x]).join(', '))} ` : '';
const row = h(`${esc(T(opt.label))}${only} ${opt.desc ? `${esc(T(opt.desc))} ` : ''}${input}
`);
row._opt = opt;
row._text = [opt.id, opt.label, T(opt.label), opt.desc || '', opt.desc ? T(opt.desc) : '', opt.kw || '', glabel, T(glabel)].join(' ').toLowerCase();
rows.push(row);
sec.append(row);
}
optsBox.append(sec);
}
const gen = () => W.generate(g.server, g.o, { nginxVersion });
const update = () => {
const q = g.q.trim().toLowerCase();
for (const row of rows) {
const opt = row._opt;
const relevant = !opt.show || opt.show(g.o);
const supported = !opt.only || opt.only.includes(g.server);
// Bei einer Suche alle passenden Optionen zeigen (auch gerade nicht relevante), sonst nur relevante
row.style.display = q ? (q.split(/\s+/).every((t) => row._text.includes(t)) ? '' : 'none') : (relevant ? '' : 'none');
row.classList.toggle('off', !supported || !relevant);
}
$$('.gen-group', optsBox).forEach((sec) => { sec.style.display = $$('.gen-row', sec).some((r) => r.style.display !== 'none') ? '' : 'none'; });
$('.gen-preview', box).textContent = gen().text;
const notes = [];
if (!inst.includes(g.server)) notes.push(T('{server} is not installed on this host – you can still generate and copy the configuration.', { server: SRV_LABEL[g.server] }));
if (g.o.tls && g.server !== 'caddy' && g.o.certSource === 'letsencrypt') notes.push(T('The certificate must exist before saving: request it in the “Certificates” tab first (e.g. via webroot), or save without HTTPS first.'));
if (g.o.tls && g.server !== 'caddy' && g.o.certSource === 'auto') notes.push(T('Automatic certificates are only available with Caddy.'));
if (g.o.basicAuth) notes.push(T('Create the password file on the server with: htpasswd -c {file} ', { file: g.o.authFile }));
$('.gen-note', box).innerHTML = notes.map((n) => `${esc(n)}
`).join('');
$('.gen-note', box).style.display = notes.length ? '' : 'none';
};
optsBox.addEventListener('input', (e) => {
const id = e.target.dataset.o; if (!id) return;
const opt = W.OPTIONS.find((x) => x.id === id);
g.o[id] = opt.type === 'bool' ? e.target.checked : opt.type === 'number' ? Number(e.target.value) : e.target.value;
// Dokumentenverzeichnis folgt der ersten Domain, bis es von Hand geändert wurde
if (id === 'domains' && !g.rootTouched) { const d = W.splitList(g.o.domains)[0]; g.o.root = d ? `/var/www/${d}` : ''; const r = $('[data-o=root]', optsBox); if (r) r.value = g.o.root; }
if (id === 'root') g.rootTouched = true;
update();
});
$('.gen-q', box).oninput = (e) => { g.q = e.target.value; update(); };
$('.gen-preset', box).onchange = (e) => {
const p = W.PRESETS.find(([id]) => id === e.target.value);
if (!p) return;
// Vorlage über die aktuellen Werte legen; Domains/Wurzel bleiben erhalten
g.o = { ...W.defaults(), domains: g.o.domains, root: g.o.root, ...(p[0] === 'secure' ? g.o : {}), ...p[2] };
this.draw();
};
$('.gen-srv', box).onclick = (e) => { const sv = e.target.closest('[data-s]')?.dataset.s; if (sv) { g.server = sv; $$('.gen-srv button', box).forEach((b) => b.classList.toggle('active', b.dataset.s === sv)); update(); } };
const siteArgs = (content) => {
const d = W.splitList(g.o.domains)[0];
const needsRoot = ['static', 'spa', 'php'].includes(g.o.type) && g.o.createRoot;
return { name: g.o.name || d || 'site', content, modules: gen().modules, mkRoot: needsRoot ? g.o.root || (d ? `/var/www/${d}` : '') : '', title: d };
};
box.addEventListener('click', async (e) => {
const a = e.target.closest('[data-g]')?.dataset.g;
if (a === 'copy') { await api.call('clipboard:write', gen().text); toast(T('Copied')); }
if (a === 'save') {
if (!W.splitList(g.o.domains).length && g.server !== 'caddy') return toast(T('Please enter at least one domain.'), 'error');
try { const r = await this.op('saveSite', g.server, siteArgs(gen().text)); toast(T('Site saved: {path}', { path: r.path }), 'ok'); await this.refresh(); this.setView('sites'); } catch {}
}
if (a === 'edit') {
const st = { path: null };
const text = gen().text;
this.editFile(T('New site ({server})', { server: SRV_LABEL[g.server] }), {
read: async () => text,
save: (c) => (st.path ? ['writeFile', st.path, c] : ['saveSite', g.server, siteArgs(c)]),
onSaved: (r) => { if (r?.path) st.path = r.path; },
note: T('Edit the generated configuration freely. Saving creates the site, checks it and reloads the web server; if the check fails, nothing is changed.'),
});
}
});
update();
}
installed() { return this.data.servers.filter((x) => x.installed); }
draw_overview(w) {
const d = this.data;
const grid = h('
');
for (const srv of d.servers) {
const card = h(`${ICONS.globe} ${esc(srv.label)} ${srv.installed ? pill(srv.active) : `${T('not installed')} `}
${srv.installed ? `
${esc(srv.version)} · ${esc(srv.unit)}
` : ''}
`);
const acts = $('.ws-acts', card);
if (srv.installed) {
acts.append(this.serviceButtons(srv));
const test = h(`${ICONS.shield}${T('Test configuration')} `);
test.onclick = async () => { try { const r = await this.op('test', srv.id); modal({ title: `${srv.label}: ${r.ok ? T('Configuration OK') : T('Configuration has errors')}`, body: `${esc(r.output || 'OK')} `, buttons: [{ label: T('OK'), value: true, cls: 'primary' }] }); } catch {} };
const logs = h(`${ICONS.logs}${T('Logs')} `);
logs.onclick = () => this.term('logsCommand', [srv.id], `${T('Logs')} ${srv.label}`);
acts.append(test, logs);
} else {
const inst = h(`${ICONS.download}${T('Install')} `);
inst.onclick = () => this.term('installCommand', [srv.id], `${T('Install')} ${srv.label}`);
acts.append(inst);
}
grid.append(card);
}
w.append(grid);
const running = d.servers.filter((x) => x.active === 'active');
if (running.length > 1) w.append(h(`${ICONS.wall}
${T('Several web servers are running ({list}). Only one of them can use port 80/443 – make sure they listen on different ports.', { list: running.map((x) => x.label).join(', ') })}
`));
const php = this.section('PHP', d.php.pools.length ? [] : [{ label: T('Install PHP-FPM'), icon: 'download', run: () => this.term('installCommand', ['php'], `${T('Install')} PHP-FPM`) }]);
if (d.php.version) php.append(h(`${esc(d.php.version)}
`));
if (d.php.pools.length) php.append(this.table(d.php.pools, [[T('Service'), (u) => `${esc(u.unit)} `], [T('Status'), (u) => pill(u.active)]], (u) => [{ icon: 'logs', title: T('Logs'), run: () => this.term('logsCommand', ['php'], `${T('Logs')} ${u.unit}`) }]));
else php.append(this.empty(T('No PHP-FPM service found.')));
if (d.php.pools.length) {
$$('tbody tr', php).forEach((tr, i) => { $('.acts', tr).prepend(this.serviceButtons(d.php.pools[i])); });
}
if (d.php.sockets.length) php.append(h(`${T('Sockets')}: ${d.php.sockets.map(esc).join(', ')}
`));
w.append(php);
}
draw_sites(w) {
const d = this.data;
const inst = this.installed();
const sec = this.section(T('Sites'), inst.length ? [{ label: T('Generator'), icon: 'code', run: () => this.setView('generator') }, { label: T('New site'), icon: 'plus', cls: 'primary', run: () => this.newSite() }] : []);
if (!inst.length) { sec.append(this.empty(T('No web server installed. Install nginx, Apache or Caddy in the overview.'))); w.append(sec); return; }
if (!d.sites.length) sec.append(this.empty(T('No sites configured yet.')));
else sec.append(this.table(d.sites, [
['', (x) => ` `, 'w-dot'],
[T('Server'), (x) => esc(SRV_LABEL[x.server])],
[T('Site'), (x) => `${esc(x.name)} ${esc(x.path)}${x.line ? `:${x.line}` : ''}
`, 'name'],
[T('Domains'), (x) => x.domains.map((n) => `${esc(n)} `).join(' ') || '– '],
], (x) => [
x.server !== 'caddy' && { icon: x.enabled ? 'stop' : 'play', title: x.enabled ? T('Disable') : T('Enable'), run: () => this.act('setSiteEnabled', [x.server, x.path, !x.enabled], x.enabled ? T('Site disabled') : T('Site enabled')) },
{ icon: 'edit', title: T('Edit'), run: () => this.editConfig(x.path) },
{ icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete site?'), x.server === 'caddy' ? T('The block “{name}” will be removed from the Caddyfile.', { name: x.name }) : T('{path} will be deleted (a copy is kept in /var/backups/mrterm).', { path: x.path }))) this.act('deleteSite', [x.server, x.path, x.name], T('Site deleted')); } },
]));
w.append(sec);
w.append(h(`${T('Every change is checked with the configuration test of the web server before reloading. If the test fails, the previous state is restored.')}
`));
}
draw_files(w) {
const d = this.data;
const files = [...new Set([...d.files, ...d.sites.map((x) => x.path)])].sort();
const sec = this.section(T('Configuration files'));
if (!files.length) sec.append(this.empty(T('No configuration files found.')));
else {
const list = h('
');
for (const f of files) { const b = h(`${ICONS.file}${esc(f)} `); b.onclick = () => this.editConfig(f); list.append(b); }
sec.append(list);
}
w.append(sec);
}
editConfig(path) {
this.editFile(path, { read: () => this.op('readFile', path), save: (c) => ['writeFile', path, c], note: T('Saving checks the configuration and reloads the service. If the check fails, the previous version is restored.') });
}
async newSite() {
const d = this.data;
const inst = this.installed();
const sock = d.php.sockets[0] || '/run/php/php-fpm.sock';
const r = await formModal({ title: T('New site'), body: `
${T('Web server')} ${inst.map((x) => `${esc(x.label)} `).join('')}
${T('Type')} ${T('Static website')} ${T('Reverse proxy')} PHP
${T('Domains')}
${T('Name of the configuration file')}
${T('Document root')}
${T('Target (upstream)')}
${T('PHP-FPM socket')} ${d.php.sockets.length > 1 ? `${d.php.sockets.map((x) => `${esc(x)} `).join('')} ` : ` `}
${T('Create the folder with a placeholder page if it does not exist')}
${T('Caddy requests HTTPS certificates for public domains automatically.')}
`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Create'), value: 'form', cls: 'primary' }] }, (m) => {
const upd = () => {
const type = $('[name=type]', m).value, server = $('[name=server]', m).value;
const show = (f, on) => { const el = $(`[data-f=${f}]`, m); if (el) el.style.display = on ? '' : 'none'; };
show('root', type !== 'proxy'); show('createRoot', type !== 'proxy'); show('upstream', type === 'proxy'); show('sock', type === 'php');
show('name', server !== 'caddy'); show('caddy', server === 'caddy');
};
$('[name=type]', m).onchange = upd; $('[name=server]', m).onchange = upd;
$('[name=domains]', m).oninput = (e) => { const r0 = $('[name=root]', m); if (!r0.dataset.touched) r0.value = `/var/www/${e.target.value.trim().split(/[\s,]+/)[0] || ''}`; };
$('[name=root]', m).oninput = (e) => { e.target.dataset.touched = '1'; };
upd();
});
if (!r) return;
const ok = await this.act('createSite', [r.server, r], T('Site created'));
if (ok && r.server !== 'caddy' && featureOn('certs')) toast(T('Tip: request an HTTPS certificate in the “Certificates” tab.'));
}
}
// ============================================================ Zertifikate (Let's Encrypt / certbot, acme.sh)
class CertSession extends AdminSession {
constructor(host) {
super(host, { kind: 'certs', ch: 'certs', title: T('Certificates'), views: [['certs', T('Certificates')], ['check', T('Check endpoint')]] });
}
draw_certs(w) {
const d = this.data;
const cb = d.certbot;
const head = h('
');
head.append(h(`certbot ${cb.version ? esc(cb.version) : `${T('not installed')} `}
`));
const timer = d.timers.find((t) => t.unit.endsWith('.timer'));
const cron = d.timers.find((t) => t.unit === 'cron');
const renew = h(`${T('Automatic renewal')}
`);
if (timer?.enabled === 'enabled' || cron) $('.v', renew).innerHTML = `${pill(timer?.active || 'active')} ${esc(timer?.unit || '/etc/cron.d/certbot')} `;
else if (cb.version) {
$('.v', renew).innerHTML = `${T('not set up')} `;
const b = h(`${T('Enable')} `);
b.onclick = () => this.act('enableAutoRenew', [timer?.unit || 'certbot.timer'], T('Automatic renewal enabled'));
$('.v', renew).append(b);
} else $('.v', renew).innerHTML = '– ';
head.append(renew);
const acts = h('
');
const btn = (label, icon, run, cls = '') => { const b = h(`${ICONS[icon]}${esc(label)} `); b.onclick = run; acts.append(b); };
if (cb.version) {
btn(T('Request certificate'), 'plus', () => this.issue(), 'primary');
btn(T('Renew all'), 'refresh', () => this.term('renewCommand', ['', false], T('Renew certificates')));
btn(T('Test renewal'), 'shield', () => this.term('renewCommand', ['', true], T('Test renewal')));
} else btn(T('Install certbot'), 'download', () => this.term('installCommand', [], `${T('Install')} certbot`), 'primary');
btn(T('Self-signed …'), 'key', () => this.selfSigned());
head.append(acts);
w.append(head);
const sec = this.section("Let's Encrypt (certbot)");
if (!cb.version) sec.append(this.empty(T('certbot is not installed. Install it to request free certificates from Let\'s Encrypt.')));
else if (!cb.certs.length) sec.append(this.empty(T('No certificates yet.')));
else sec.append(this.table(cb.certs, [
[T('Name'), (c) => `${esc(c.name)} ${esc(c.keyType || '')}
`, 'name'],
[T('Domains'), (c) => c.domains.map((x) => `${esc(x)} `).join(' ')],
[T('Expires'), (c) => expiryHtml(c.expires)],
], (c) => [
{ icon: 'refresh', title: T('Renew'), run: () => this.term('renewCommand', [c.name, false], `${T('Renew')} ${c.name}`) },
{ icon: 'eye', title: T('Details'), run: () => this.details(c.certPath) },
{ icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete certificate?'), T('“{name}” and its files will be deleted. Web server configurations that still use it will fail.', { name: c.name }))) this.act('deleteCert', [c.name], T('Certificate deleted')); } },
]));
w.append(sec);
if (d.acme.dir) {
const a = this.section('acme.sh');
if (!d.acme.certs.length) a.append(this.empty(T('No certificates yet.')));
else a.append(this.table(d.acme.certs, [
[T('Domain'), (c) => `${esc(c.domain)} `, 'name'], ['SAN', (c) => c.sans.map((x) => `${esc(x)} `).join(' ')],
['CA', (c) => esc(c.ca)], [T('Next renewal'), (c) => esc(c.renew)],
], (c) => [
{ icon: 'refresh', title: T('Renew'), run: () => this.term('acmeRenewCommand', [c.domain], `acme.sh ${c.domain}`) },
{ icon: 'trash', title: T('Remove'), danger: true, run: async () => { if (await confirmBox(T('Remove certificate?'), T('acme.sh will no longer renew “{name}”.', { name: c.domain }), T('Remove'))) this.act('acmeRemove', [c.domain], T('Removed')); } },
]));
w.append(a);
}
const f = this.section(T('Certificate files'));
const files = d.files.filter((x) => !x.path.startsWith('/etc/letsencrypt/'));
if (!files.length) f.append(this.empty(T('No other certificate files found (searched /etc/ssl/mrterm, /etc/nginx/ssl, /etc/apache2/ssl, Caddy).')));
else f.append(this.table(files, [
[T('File'), (c) => `${esc(c.path)} ${esc(c.issuer)}
`, 'name'],
[T('Domains'), (c) => (c.sans.length ? c.sans : [c.subject]).map((x) => `${esc(x)} `).join(' ')],
[T('Expires'), (c) => expiryHtml(c.expires)],
], (c) => [{ icon: 'eye', title: T('Details'), run: () => this.details(c.path) }]));
w.append(f);
}
async details(path) {
try {
const text = await this.op('details', path);
modal({ title: path, body: `${esc(text)} `, buttons: [{ label: T('OK'), value: true, cls: 'primary' }] });
} catch {}
}
async issue() {
const d = this.data;
const plug = d.certbot.plugins;
const methods = [
...(plug.includes('nginx') ? [['nginx', T('nginx (configures HTTPS automatically)')]] : []),
...(plug.includes('apache') ? [['apache', T('Apache (configures HTTPS automatically)')]] : []),
['webroot', T('Webroot (running web server, files in .well-known)')],
['standalone', T('Standalone (port 80 must be free)')],
['dns', T('DNS challenge (manual TXT record, also for wildcards)')],
];
const r = await formModal({ title: T('Request certificate'), body: `
${T('Domains')}
${T('Method')} ${methods.map(([v, l]) => `${esc(l)} `).join('')}
${T('Webroot')}
${T('E-mail for expiry notices')}
${T('Redirect HTTP to HTTPS')}
${T('Test certificate (Let\'s Encrypt staging, no rate limits)')}
${T('certbot runs in a terminal tab so you can follow the output and answer questions.')}
`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Request'), value: 'form', cls: 'primary' }] }, (m) => {
const upd = () => { const v = $('[name=method]', m).value; $('[data-f=webroot]', m).style.display = v === 'webroot' ? '' : 'none'; $('[data-f=redirect]', m).style.display = v === 'nginx' || v === 'apache' ? '' : 'none'; };
$('[name=method]', m).onchange = upd; upd();
});
if (r) this.term('issueCommand', [r], 'certbot');
}
async selfSigned() {
const r = await modal({ title: T('Self-signed certificate'), body: `
${T('Common name (CN)')}
${T('Additional names / IPs')}
${T('Key type')} ECDSA P-256 RSA 4096
${T('Stored in /etc/ssl/mrterm/.crt and .key. Browsers show a warning for self-signed certificates.')}
`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Create'), value: 'form', cls: 'primary' }] });
if (!r) return;
try {
const res = await this.op('selfSigned', r);
toast(T('Created: {path}', { path: res.cert }), 'ok');
await this.refresh();
} catch {}
}
draw_check(w) {
const sec = this.section(T('Check TLS endpoint'));
const form = h(``);
const go = h(`${ICONS.search}${T('Check')} `);
const out = h('
');
go.onclick = async () => {
this.probeHost = $('[name=host]', form).value.trim(); this.probePort = $('[name=port]', form).value;
out.innerHTML = spinnerHtml(T('Checking …'));
try {
const r = await api.call('certs:probe', this.probeHost, Number(this.probePort) || 443);
out.innerHTML = `${T('Status')} ${r.authorized ? `${T('trusted')} ` : `${esc(r.error || T('not trusted'))} `}
${T('Expires')} ${expiryHtml(r.expires)}
${T('Issuer')} ${esc(r.issuer)}
${T('Subject')} ${esc(r.subject)}
${T('Protocol')} ${esc(r.protocol)}
${T('Names')} ${r.sans.map((x) => `${esc(x)} `).join(' ')}
SHA-256 ${esc(r.fingerprint)}
`;
} catch (e) { out.innerHTML = `${esc(e.message)}
`; }
};
form.append(go);
sec.append(form, h(`${T('The connection is made from this computer – useful to check which certificate a web server actually delivers.')}
`), out);
w.append(sec);
}
}
// ============================================================ Samba / Active Directory
const YES = (v) => /^(yes|true|1)$/i.test(String(v));
class SambaSession extends AdminSession {
constructor(host) {
super(host, { kind: 'samba', ch: 'samba', title: 'Samba', views: [['shares', T('Shares')], ['users', T('Users')], ['status', T('Status')], ['domain', T('Domain')]] });
this.adView = 'users';
}
notInstalled(w) {
const box = h(`${ICONS.users}${T('Samba is not installed')} ${T('Install Samba to share folders with Windows clients or to run a domain controller.')}
`);
const b = h(`${ICONS.download}${T('Install Samba')} `);
b.onclick = () => this.term('installCommand', ['samba'], `${T('Install')} Samba`);
$('.fw-off', box).append(b);
w.append(box);
}
summary(w) {
const d = this.data;
const g = d.global;
w.append(h(`${T('Version')} ${esc(d.version || '–')}
${T('Server role')} ${esc(d.role || '–')}
${T('Workgroup')} ${esc(g.workgroup || '–')}
${g.realm ? `
` : ''}
`));
}
draw_shares(w) {
const d = this.data;
if (!d.installed) return this.notInstalled(w);
this.summary(w);
const sec = this.section(T('Shares'), [
{ label: T('Edit smb.conf'), icon: 'edit', run: () => this.editConf() },
{ label: T('New share'), icon: 'plus', cls: 'primary', run: () => this.editShare(null) },
]);
if (!d.shares.length) sec.append(this.empty(T('No shares yet.')));
else sec.append(this.table(d.shares, [
[T('Share'), (s) => `${esc(s.name)} ${s.comment ? `${esc(s.comment)}
` : ''}`, 'name'],
[T('Path'), (s) => `${esc(s.path || '–')} `],
[T('Access'), (s) => `${YES(s.readOnly) ? T('read only') : T('read/write')} ${YES(s.guestOk) ? ` ${T('guest')} ` : ''}${YES(s.browseable) ? '' : ` ${T('hidden')} `}`],
[T('Users'), (s) => esc(s.validUsers || T('all'))],
], (s) => [
{ icon: 'edit', title: T('Edit'), run: () => (s.special ? this.editConf() : this.editShare(s)) },
!s.special && { icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete share?'), T('The share “{name}” will be removed from smb.conf. The files in {path} are kept.', { name: s.name, path: s.path || '–' }))) this.act('deleteShare', [s.name], T('Share deleted')); } },
]));
w.append(sec);
}
editConf() {
this.editFile('/etc/samba/smb.conf', { read: () => this.op('readConf'), save: (c) => ['writeConf', c], note: T('Saving checks the file with testparm and reloads Samba. If the check fails, the previous version is restored.') });
}
async editShare(s) {
const v = s || { readOnly: 'no', browseable: 'yes', guestOk: 'no' };
const r = await modal({ title: s ? T('Edit share') : T('New share'), body: `
${T('Comment')}
${T('Read only')}
${T('Visible in the network')}
${T('Guest access')}
${T('Allowed users / groups')}
${T('Users with write access')}
${T('Create the folder if it does not exist')} `,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
if (!r) return;
const yn = (b) => (b ? 'yes' : 'no');
const known = ['path', 'comment', 'read only', 'browseable', 'guest ok', 'valid users', 'write list', 'create mask', 'directory mask'];
const extra = s ? Object.fromEntries(Object.entries(s.params || {}).filter(([k]) => !known.includes(k))) : {};
this.act('saveShare', [{ ...r, readOnly: yn(r.readOnly), browseable: yn(r.browseable), guestOk: yn(r.guestOk), extra }, s?.name || null, r.createDir], T('Share saved'));
}
draw_users(w) {
const d = this.data;
if (!d.installed) return this.notInstalled(w);
if (d.dc) {
w.append(h(`${ICONS.users}
${T('This server is an Active Directory domain controller. Users are managed in the “Domain” view.')}
`));
return;
}
const sec = this.section(T('Samba users'), [{ label: T('Add user'), icon: 'plus', cls: 'primary', run: () => this.addUser() }]);
if (!d.users.length) sec.append(this.empty(T('No Samba users yet. Samba users need a Linux account and their own Samba password.')));
else sec.append(this.table(d.users, [
[T('User'), (u) => `${esc(u.user)} ${u.fullName ? `${esc(u.fullName)}
` : ''}`, 'name'],
[T('Status'), (u) => (u.disabled ? `${T('disabled')} ` : `${T('active')} `)],
[T('Flags'), (u) => `${esc(u.flags)} `],
], (u) => [
{ icon: 'key', title: T('Set password'), run: () => this.password((pw) => this.act('setPassword', [u.user, pw], T('Password changed')), u.user) },
{ icon: u.disabled ? 'play' : 'stop', title: u.disabled ? T('Enable') : T('Disable'), run: () => this.act('setUserEnabled', [u.user, u.disabled], u.disabled ? T('User enabled') : T('User disabled')) },
{ icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete Samba user?'), T('“{name}” can no longer access shares. The Linux account is kept.', { name: u.user }))) this.act('deleteUser', [u.user], T('User deleted')); } },
]));
w.append(sec);
}
async password(run, name) {
const r = await modal({ title: T('Set password'), text: name, body: `${T('New password')}
${T('Repeat password')}
`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
if (!r) return;
if (!r.pw || r.pw !== r.pw2) return toast(T('The passwords do not match.'), 'error');
run(r.pw, r);
}
async addUser() {
const r = await modal({ title: T('Add Samba user'), body: `
${T('Username')}
${T('Create a Linux account without login if it does not exist')} `,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Add'), value: 'form', cls: 'primary' }] });
if (!r) return;
if (!r.pw || r.pw !== r.pw2) return toast(T('The passwords do not match.'), 'error');
this.act('addUser', [r.user.trim(), r.pw, r.createUnix], T('User added'));
}
draw_status(w) {
const d = this.data;
if (!d.installed) return this.notInstalled(w);
const svc = this.section(T('Services'));
if (!d.units.length) svc.append(this.empty(T('No Samba services found.')));
else {
svc.append(this.table(d.units, [[T('Service'), (u) => `${esc(u.unit)} `], [T('Status'), (u) => pill(u.active)]], () => []));
$$('tbody tr', svc).forEach((tr, i) => $('.acts', tr).append(this.serviceButtons(d.units[i], 'service', false)));
}
w.append(svc);
const st = d.status;
const con = this.section(T('Connected clients'));
if (st.raw !== undefined) con.append(h(`${esc(st.raw || T('No connections.'))} `));
else if (!st.sessions.length) con.append(this.empty(T('No connections.')));
else con.append(this.table(st.sessions, [[T('User'), (x) => `${esc(x.user)} `], [T('Computer'), (x) => esc(x.machine || x.host)], [T('Protocol'), (x) => esc(x.protocol)], [T('Encryption'), (x) => esc(x.encryption || '–')],
[T('Shares'), (x) => st.shares.filter((t) => t.session === x.id).map((t) => `${esc(t.service)} `).join(' ')]]));
w.append(con);
if (st.files?.length) {
const f = this.section(T('Open files'));
f.append(this.table(st.files, [[T('File'), (x) => `${esc(x.path)} `]]));
w.append(f);
}
}
async draw_domain(w) {
const d = this.data;
if (!d.dc) return this.drawMembership(w);
const seg = h(`${[['users', T('Users')], ['groups', T('Groups')], ['computers', T('Computers')], ['info', T('Domain info')]].map(([v, l]) => `${esc(l)} `).join('')}
`);
seg.onclick = (e) => { const v = e.target.closest('[data-ad]')?.dataset.ad; if (v) { this.adView = v; this.draw(); } };
w.append(seg);
const box = h(`${spinnerHtml(T('Loading …'))}
`);
w.append(box);
try { if (!this.ad || this.adStale) { this.ad = await this.op('adRead'); this.adStale = false; } } catch { box.innerHTML = ''; return; }
box.innerHTML = '';
const ad = this.ad;
if (ad.limited) box.append(h(`${ICONS.wall}
${T('ldbsearch is missing (package ldb-tools), so only names are shown.')}
`));
const reloadAd = async (name, args, msg) => { this.adStale = true; return this.act(name, args, msg); };
if (this.adView === 'users') {
const sec = this.section(T('Domain users'), [{ label: T('New user'), icon: 'plus', cls: 'primary', run: () => this.adNewUser(reloadAd) }]);
sec.append(this.table(ad.users, [
[T('User'), (u) => `${esc(u.name)} ${u.display ? `${esc(u.display)}
` : ''}`, 'name'],
[T('E-mail'), (u) => esc(u.mail || '')],
[T('Groups'), (u) => (u.groups || []).slice(0, 4).map((g) => `${esc(g)} `).join(' ') + ((u.groups || []).length > 4 ? ` +${u.groups.length - 4} ` : '')],
[T('Last logon'), (u) => (u.lastLogon ? esc(relTime(u.lastLogon)) : '– ')],
[T('Status'), (u) => (u.disabled ? `${T('disabled')} ` : `${T('active')} `)],
], (u) => [
{ icon: 'key', title: T('Set password'), run: () => this.adPassword(u.name, reloadAd) },
{ icon: u.disabled ? 'play' : 'stop', title: u.disabled ? T('Enable') : T('Disable'), run: () => reloadAd('adUserEnable', [u.name, !!u.disabled], u.disabled ? T('User enabled') : T('User disabled')) },
!/^(Administrator|krbtgt|Guest)$/i.test(u.name) && { icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete domain user?'), T('“{name}” will be permanently deleted from the domain.', { name: u.name }))) reloadAd('adUserDelete', [u.name], T('User deleted')); } },
]));
box.append(sec);
} else if (this.adView === 'groups') {
const sec = this.section(T('Groups'), [{ label: T('New group'), icon: 'plus', cls: 'primary', run: async () => {
const r = await modal({ title: T('New group'), body: `${T('Name')}
${T('Description')}
`, buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Create'), value: 'form', cls: 'primary' }] });
if (r) reloadAd('adGroupCreate', [r.name, r.description], T('Group created'));
} }]);
sec.append(this.table(ad.groups, [
[T('Group'), (g) => `${esc(g.name)} ${g.description ? `${esc(g.description)}
` : ''}`, 'name'],
[T('Members'), (g) => `${(g.members || []).length}`, 'num'],
], (g) => [
{ icon: 'users', title: T('Members'), run: () => this.adMembers(g, reloadAd) },
!g.builtin && { icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete group?'), T('The group “{name}” will be deleted.', { name: g.name }))) reloadAd('adGroupDelete', [g.name], T('Group deleted')); } },
]));
box.append(sec);
} else if (this.adView === 'computers') {
const sec = this.section(T('Computers'));
sec.append(this.table(ad.computers, [
[T('Computer'), (c) => `${esc(c.name)} ${c.dns ? `${esc(c.dns)}
` : ''}`, 'name'],
[T('Operating system'), (c) => esc(c.os || '')],
[T('Last logon'), (c) => (c.lastLogon ? esc(relTime(c.lastLogon)) : '– ')],
], (c) => [{ icon: 'trash', title: T('Delete'), danger: true, run: async () => { if (await confirmBox(T('Delete computer account?'), T('“{name}” will be removed from the domain and must join again.', { name: c.name }))) reloadAd('adComputerDelete', [c.name], T('Computer deleted')); } }]));
box.append(sec);
} else {
box.append(h(`${esc([ad.info, ad.level, ad.fsmo].filter(Boolean).join('\n\n'))} `));
}
}
// Kein Domänencontroller: Mitgliedschaft anzeigen, beitreten/verlassen oder eine neue Domäne erstellen
drawMembership(w) {
const d = this.data;
const tools = new Set(d.tools);
const joined = (d.realm.match(/^(\S+)\s*$/m) || [])[1];
const btn = (label, icon, run, cls = '') => { const b = h(`${ICONS[icon]}${esc(label)} `); b.onclick = run; return b; };
const mem = this.section(T('Domain membership'));
if (joined) {
mem.append(h(`${ICONS.users}
${T('This server is a member of the domain {domain}.', { domain: `${esc(joined)} ` })}
`));
mem.append(h(`${esc(d.realm)} `));
const acts = h('
');
acts.append(btn(T('Leave domain'), 'close', async () => { if (await confirmBox(T('Leave domain?'), T('Domain users can no longer log in on this server. The computer account in the domain is kept.'), T('Leave'))) this.act('leaveDomain', [joined], T('Left the domain')); }, 'danger'));
mem.append(acts);
} else {
mem.append(this.empty(T('This server is not a member of a domain.')));
if (d.winbind) mem.append(h(`winbind: ${esc(d.winbind)}
`));
const acts = h('
');
if (tools.has('realm')) acts.append(btn(T('Join domain …'), 'plus', () => this.joinDialog(), 'primary'));
else acts.append(btn(T('Install domain join tools'), 'download', () => this.term('installCommand', ['join'], `${T('Install')} realmd/sssd`), 'primary'));
mem.append(acts);
}
w.append(mem);
if (!joined) {
const dc = this.section(T('Create a new domain'));
dc.append(h(`${T('Turns this server into the first domain controller of a new Active Directory domain. Windows and Linux computers can then join it, and you manage users, groups and computers right here.')}
`));
const acts = h('
');
if (tools.has('samba-tool')) acts.append(btn(T('Create domain …'), 'plus', () => this.provisionDialog(), 'primary'));
acts.append(btn(T('Install domain controller packages'), 'download', () => this.term('installCommand', ['dc'], `${T('Install')} Samba AD DC`)));
dc.append(acts);
w.append(dc);
}
}
async joinDialog() {
const r = await modal({ title: T('Join domain'), body: `
${T('Domain')}
${T('The server must resolve the domain via DNS (the domain controller as DNS server). Logins of domain users are then handled by sssd.')}
`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Join'), value: 'form', cls: 'primary' }] });
if (!r) return;
this.body.innerHTML = spinnerHtml(T('Joining {domain} …', { domain: r.domain }));
if (!(await this.act('joinDomain', [r.domain, r.admin, r.password], T('Joined the domain')))) this.draw();
}
async provisionDialog() {
const host = (this.host.address || '').includes('.') && !/^\d/.test(this.host.address) ? this.host.address.split('.').slice(1).join('.') : '';
const r = await formModal({ title: T('Create a new domain'), body: `
${T('Use this domain controller as DNS server of the host (resolv.conf)')}
${T('Store Unix attributes (UID/GID) in the directory (RFC 2307)')}
${ICONS.wall}
${T('The file server services (smbd/nmbd) are replaced by samba-ad-dc; the previous smb.conf is kept as smb.conf.mrterm-before-dc. The server needs a fixed IP address and a hostname that is not the domain name. The password must be complex (upper/lower case, digits, 8+ characters).')}
`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Create domain'), value: 'form', cls: 'primary' }] }, (m) => {
$('[name=realm]', m).oninput = (e) => { const n = $('[name=domain]', m); if (!n.dataset.touched) n.value = (e.target.value.split('.')[0] || '').toUpperCase().slice(0, 15); };
$('[name=domain]', m).oninput = (e) => { e.target.dataset.touched = '1'; };
$('[name=realm]', m).dispatchEvent(new Event('input'));
});
if (!r) return;
if (!r.password || r.password !== r.password2) return toast(T('The passwords do not match.'), 'error');
this.body.innerHTML = spinnerHtml(T('Creating the domain {realm} … this takes about a minute.', { realm: r.realm.toUpperCase() }));
this.ad = null;
if (await this.act('provisionDomain', [r], T('Domain {realm} created', { realm: r.realm.toUpperCase() }))) { this.adView = 'info'; this.draw(); } else this.draw();
}
async adNewUser(reloadAd) {
const r = await modal({ title: T('New domain user'), body: `
${T('Username')}
${T('E-mail')}
${T('User must change the password at next logon')} `,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Create'), value: 'form', cls: 'primary' }] });
if (!r) return;
if (!r.password || r.password !== r.password2) return toast(T('The passwords do not match.'), 'error');
reloadAd('adUserCreate', [r], T('User created'));
}
async adPassword(name, reloadAd) {
const r = await modal({ title: T('Set password'), text: name, body: `${T('New password')}
${T('Repeat password')}
${T('User must change the password at next logon')} `,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
if (!r) return;
if (!r.pw || r.pw !== r.pw2) return toast(T('The passwords do not match.'), 'error');
reloadAd('adSetPassword', [name, r.pw, r.mustChange], T('Password changed'));
}
async adMembers(g, reloadAd) {
const users = (this.ad.users || []).map((u) => u.name);
const r = await modal({ title: T('Members of {name}', { name: g.name }), body: `${T('One member per line (users, groups or computers)')}
${users.length ? `${T('Users')}: ${users.slice(0, 40).map(esc).join(', ')}${users.length > 40 ? ' …' : ''}
` : ''}`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
if (!r) return;
const now = r.members.split('\n').map((x) => x.trim()).filter(Boolean);
const before = g.members || [];
const add = now.filter((x) => !before.includes(x)), remove = before.filter((x) => !now.includes(x));
if (add.length || remove.length) reloadAd('adGroupMembers', [g.name, add, remove], T('Members updated'));
}
}